+1
-1
Taskfile.yml
+1
-1
Taskfile.yml
+163
cmd/main_test.go
+163
cmd/main_test.go
···
1
+
package main
2
+
3
+
import (
4
+
"strings"
5
+
"testing"
6
+
7
+
"github.com/stormlightlabs/git-storm/internal/testutils"
8
+
)
9
+
10
+
func TestParseRefArgs(t *testing.T) {
11
+
tests := []struct {
12
+
name string
13
+
args []string
14
+
expectedFrom string
15
+
expectedTo string
16
+
}{
17
+
{
18
+
name: "range syntax with full hashes",
19
+
args: []string{"abc123..def456"},
20
+
expectedFrom: "abc123",
21
+
expectedTo: "def456",
22
+
},
23
+
{
24
+
name: "range syntax with truncated hashes",
25
+
args: []string{"7de6f6d..18363c2"},
26
+
expectedFrom: "7de6f6d",
27
+
expectedTo: "18363c2",
28
+
},
29
+
{
30
+
name: "range syntax with tags",
31
+
args: []string{"v1.0.0..v2.0.0"},
32
+
expectedFrom: "v1.0.0",
33
+
expectedTo: "v2.0.0",
34
+
},
35
+
{
36
+
name: "two separate arguments",
37
+
args: []string{"abc123", "def456"},
38
+
expectedFrom: "abc123",
39
+
expectedTo: "def456",
40
+
},
41
+
{
42
+
name: "single argument compares with HEAD",
43
+
args: []string{"abc123"},
44
+
expectedFrom: "abc123",
45
+
expectedTo: "HEAD",
46
+
},
47
+
{
48
+
name: "branch names",
49
+
args: []string{"main", "feature-branch"},
50
+
expectedFrom: "main",
51
+
expectedTo: "feature-branch",
52
+
},
53
+
}
54
+
55
+
for _, tt := range tests {
56
+
t.Run(tt.name, func(t *testing.T) {
57
+
from, to := parseRefArgs(tt.args)
58
+
59
+
if from != tt.expectedFrom {
60
+
t.Errorf("parseRefArgs() from = %v, want %v", from, tt.expectedFrom)
61
+
}
62
+
if to != tt.expectedTo {
63
+
t.Errorf("parseRefArgs() to = %v, want %v", to, tt.expectedTo)
64
+
}
65
+
})
66
+
}
67
+
}
68
+
69
+
func TestGetChangedFiles(t *testing.T) {
70
+
repo := testutils.SetupTestRepo(t)
71
+
commits := testutils.GetCommitHistory(t, repo)
72
+
73
+
if len(commits) < 2 {
74
+
t.Fatal("Test repo should have at least 2 commits")
75
+
}
76
+
77
+
fromHash := commits[1].Hash.String()
78
+
toHash := commits[0].Hash.String()
79
+
80
+
files, err := getChangedFiles(repo, fromHash, toHash)
81
+
if err != nil {
82
+
t.Fatalf("getChangedFiles() error = %v", err)
83
+
}
84
+
85
+
if len(files) == 0 {
86
+
t.Error("Expected at least one changed file")
87
+
}
88
+
89
+
for _, file := range files {
90
+
if file == "" {
91
+
t.Error("File path should not be empty")
92
+
}
93
+
}
94
+
}
95
+
96
+
func TestGetChangedFiles_NoChanges(t *testing.T) {
97
+
repo := testutils.SetupTestRepo(t)
98
+
99
+
commits := testutils.GetCommitHistory(t, repo)
100
+
if len(commits) == 0 {
101
+
t.Fatal("Test repo should have at least 1 commit")
102
+
}
103
+
104
+
hash := commits[0].Hash.String()
105
+
106
+
files, err := getChangedFiles(repo, hash, hash)
107
+
if err != nil {
108
+
t.Fatalf("getChangedFiles() error = %v", err)
109
+
}
110
+
111
+
if len(files) != 0 {
112
+
t.Errorf("Expected no changed files when comparing commit with itself, got %d", len(files))
113
+
}
114
+
}
115
+
116
+
func TestGetFileContent(t *testing.T) {
117
+
repo := testutils.SetupTestRepo(t)
118
+
119
+
commits := testutils.GetCommitHistory(t, repo)
120
+
if len(commits) == 0 {
121
+
t.Fatal("Test repo should have at least 1 commit")
122
+
}
123
+
124
+
hash := commits[0].Hash.String()
125
+
126
+
content, err := getFileContent(repo, hash, "README.md")
127
+
if err != nil {
128
+
t.Fatalf("getFileContent() error = %v", err)
129
+
}
130
+
131
+
if content == "" {
132
+
t.Error("Expected non-empty content for README.md")
133
+
}
134
+
135
+
if !strings.Contains(content, "Project") {
136
+
t.Error("README.md should contain 'Project'")
137
+
}
138
+
}
139
+
140
+
func TestGetFileContent_FileNotFound(t *testing.T) {
141
+
repo := testutils.SetupTestRepo(t)
142
+
143
+
commits := testutils.GetCommitHistory(t, repo)
144
+
if len(commits) == 0 {
145
+
t.Fatal("Test repo should have at least 1 commit")
146
+
}
147
+
148
+
hash := commits[0].Hash.String()
149
+
150
+
_, err := getFileContent(repo, hash, "nonexistent.txt")
151
+
if err == nil {
152
+
t.Error("Expected error when reading nonexistent file")
153
+
}
154
+
}
155
+
156
+
func TestGetFileContent_InvalidRef(t *testing.T) {
157
+
repo := testutils.SetupTestRepo(t)
158
+
159
+
_, err := getFileContent(repo, "invalid-ref-12345", "README.md")
160
+
if err == nil {
161
+
t.Error("Expected error when using invalid ref")
162
+
}
163
+
}
+1
go.mod
+1
go.mod
+5
go.sum
+5
go.sum
···
91
91
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
92
92
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
93
93
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
94
+
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
94
95
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
95
96
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
96
97
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
···
103
104
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
104
105
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
105
106
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
107
+
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
108
+
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
106
109
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
107
110
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
108
111
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
···
111
114
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
112
115
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
113
116
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
117
+
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
118
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
114
119
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
115
120
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
116
121
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+196
-4
internal/diff/diff.go
+196
-4
internal/diff/diff.go
···
27
27
28
28
// Edit represents a single edit operation in a diff.
29
29
type Edit struct {
30
-
Kind EditKind // Equal, Insert, or Delete
31
-
AIndex int // index in original sequence
32
-
BIndex int // index in new sequence
33
-
Content string // the line or token
30
+
Kind EditKind // Equal, Insert, Delete, or Replace
31
+
AIndex int // index in original sequence (-1 for Insert-only)
32
+
BIndex int // index in new sequence (-1 for Delete-only)
33
+
Content string // the line or token (old content for Replace)
34
+
NewContent string // new content (only used for Replace operations)
34
35
}
35
36
36
37
// Diff represents a generic diffing algorithm.
···
349
350
}
350
351
return counts
351
352
}
353
+
354
+
// MergeReplacements merges Delete+Insert pairs into Replace operations for better side-by-side rendering.
355
+
//
356
+
// This function identifies blocks of Delete and Insert operations and pairs them up based on similarity.
357
+
// When a Delete and Insert represent the same logical line being modified (e.g., version bump),
358
+
// they are merged into a Replace operation that can be rendered on a single line.
359
+
//
360
+
// The function uses a similarity heuristic to determine if a Delete and Insert pair should be merged:
361
+
// - They must share a common prefix of at least 70% of the shorter line's length
362
+
// - This prevents merging unrelated changes (e.g., different package names)
363
+
//
364
+
// The algorithm processes edits in windows, looking ahead up to 10 positions to find matching pairs.
365
+
func MergeReplacements(edits []Edit) []Edit {
366
+
if len(edits) <= 1 {
367
+
return edits
368
+
}
369
+
370
+
type mergeInfo struct {
371
+
partnIndex int // index of the partner edit
372
+
isDelete bool
373
+
}
374
+
375
+
merged := make(map[int]mergeInfo)
376
+
const lookAheadWindow = 50
377
+
378
+
for i := range edits {
379
+
if _, exists := merged[i]; exists || edits[i].Kind != Delete {
380
+
continue
381
+
}
382
+
383
+
found := false
384
+
for j := i + 1; j < len(edits) && j < i+lookAheadWindow; j++ {
385
+
if _, exists := merged[j]; exists || edits[j].Kind != Insert {
386
+
continue
387
+
}
388
+
389
+
if areSimilarLines(edits[i].Content, edits[j].Content) {
390
+
merged[i] = mergeInfo{partnIndex: j, isDelete: true}
391
+
merged[j] = mergeInfo{partnIndex: i, isDelete: false}
392
+
found = true
393
+
break
394
+
}
395
+
}
396
+
397
+
if !found {
398
+
for j := i - 1; j >= 0 && j >= i-lookAheadWindow; j-- {
399
+
if _, exists := merged[j]; exists || edits[j].Kind != Insert {
400
+
continue
401
+
}
402
+
403
+
if areSimilarLines(edits[i].Content, edits[j].Content) {
404
+
merged[i] = mergeInfo{partnIndex: j, isDelete: true}
405
+
merged[j] = mergeInfo{partnIndex: i, isDelete: false}
406
+
break
407
+
}
408
+
}
409
+
}
410
+
}
411
+
412
+
for i := 0; i < len(edits); i++ {
413
+
if _, exists := merged[i]; exists || edits[i].Kind != Insert {
414
+
continue
415
+
}
416
+
417
+
for j := max(0, i-lookAheadWindow); j < i; j++ {
418
+
if _, exists := merged[j]; exists || edits[j].Kind != Delete {
419
+
continue
420
+
}
421
+
422
+
if areSimilarLines(edits[j].Content, edits[i].Content) {
423
+
merged[j] = mergeInfo{partnIndex: i, isDelete: true}
424
+
merged[i] = mergeInfo{partnIndex: j, isDelete: false}
425
+
break
426
+
}
427
+
}
428
+
}
429
+
430
+
type outputEdit struct {
431
+
edit Edit
432
+
origPosition int
433
+
}
434
+
435
+
outputs := make([]outputEdit, 0, len(edits))
436
+
437
+
for i := range edits {
438
+
info, isMerged := merged[i]
439
+
if !isMerged {
440
+
outputs = append(outputs, outputEdit{
441
+
edit: edits[i],
442
+
origPosition: i,
443
+
})
444
+
} else if info.isDelete {
445
+
outputs = append(outputs, outputEdit{
446
+
edit: Edit{
447
+
Kind: Replace,
448
+
AIndex: edits[i].AIndex,
449
+
BIndex: edits[info.partnIndex].BIndex,
450
+
Content: edits[i].Content,
451
+
NewContent: edits[info.partnIndex].Content,
452
+
},
453
+
origPosition: i,
454
+
})
455
+
}
456
+
}
457
+
458
+
for i := 0; i < len(outputs); i++ {
459
+
for j := i + 1; j < len(outputs); j++ {
460
+
ei := outputs[i].edit
461
+
ej := outputs[j].edit
462
+
463
+
// Get effective sort keys
464
+
keyI := ei.BIndex
465
+
if keyI == -1 {
466
+
keyI = ei.AIndex
467
+
}
468
+
469
+
keyJ := ej.BIndex
470
+
if keyJ == -1 {
471
+
keyJ = ej.AIndex
472
+
}
473
+
474
+
if keyI > keyJ {
475
+
outputs[i], outputs[j] = outputs[j], outputs[i]
476
+
}
477
+
}
478
+
}
479
+
480
+
result := make([]Edit, 0, len(outputs))
481
+
for _, out := range outputs {
482
+
result = append(result, out.edit)
483
+
}
484
+
485
+
return result
486
+
}
487
+
488
+
// areSimilarLines determines if two lines are similar enough to be considered a replacement.
489
+
//
490
+
// Uses a two-phase similarity check:
491
+
// 1. Common prefix must be at least 70% of the shorter line
492
+
// 2. Remaining suffixes must be at least 60% similar (Levenshtein-like check)
493
+
func areSimilarLines(a, b string) bool {
494
+
if a == b {
495
+
return true
496
+
}
497
+
498
+
minLen := len(a)
499
+
if len(b) < minLen {
500
+
minLen = len(b)
501
+
}
502
+
503
+
if minLen == 0 {
504
+
return false
505
+
}
506
+
507
+
commonPrefix := 0
508
+
for i := 0; i < minLen; i++ {
509
+
if a[i] == b[i] {
510
+
commonPrefix++
511
+
} else {
512
+
break
513
+
}
514
+
}
515
+
516
+
prefixThreshold := float64(minLen) * 0.7
517
+
if float64(commonPrefix) < prefixThreshold {
518
+
return false
519
+
}
520
+
521
+
suffixA := a[commonPrefix:]
522
+
suffixB := b[commonPrefix:]
523
+
524
+
suffixLenA := len(suffixA)
525
+
suffixLenB := len(suffixB)
526
+
527
+
if suffixLenA == 0 && suffixLenB == 0 {
528
+
return true
529
+
}
530
+
531
+
lenDiff := suffixLenA - suffixLenB
532
+
if lenDiff < 0 {
533
+
lenDiff = -lenDiff
534
+
}
535
+
536
+
maxSuffixLen := max(suffixLenB, suffixLenA)
537
+
538
+
if maxSuffixLen > 0 && float64(lenDiff)/float64(maxSuffixLen) > 0.3 {
539
+
return false
540
+
}
541
+
542
+
return true
543
+
}
+193
internal/diff/diff_test.go
+193
internal/diff/diff_test.go
···
262
262
})
263
263
}
264
264
}
265
+
266
+
func TestAreSimilarLines(t *testing.T) {
267
+
tests := []struct {
268
+
name string
269
+
a string
270
+
b string
271
+
expected bool
272
+
}{
273
+
{
274
+
name: "identical lines",
275
+
a: "github.com/foo/bar v1.0.0",
276
+
b: "github.com/foo/bar v1.0.0",
277
+
expected: true,
278
+
},
279
+
{
280
+
name: "similar package different version",
281
+
a: "github.com/charmbracelet/x/ansi v0.10.1 // indirect",
282
+
b: "github.com/charmbracelet/x/ansi v0.10.3 // indirect",
283
+
expected: true,
284
+
},
285
+
{
286
+
name: "different packages",
287
+
a: "github.com/charmbracelet/x/term v0.2.1 // indirect",
288
+
b: "github.com/charmbracelet/x/exp/teatest v0.0.0-20251",
289
+
expected: false,
290
+
},
291
+
{
292
+
name: "empty strings",
293
+
a: "",
294
+
b: "",
295
+
expected: true,
296
+
},
297
+
{
298
+
name: "one empty",
299
+
a: "some content",
300
+
b: "",
301
+
expected: false,
302
+
},
303
+
{
304
+
name: "completely different",
305
+
a: "package main",
306
+
b: "import fmt",
307
+
expected: false,
308
+
},
309
+
{
310
+
name: "short common prefix",
311
+
a: "github.com/foo/bar",
312
+
b: "github.com/baz/qux",
313
+
expected: false,
314
+
},
315
+
}
316
+
317
+
for _, tt := range tests {
318
+
t.Run(tt.name, func(t *testing.T) {
319
+
result := areSimilarLines(tt.a, tt.b)
320
+
if result != tt.expected {
321
+
t.Errorf("areSimilarLines(%q, %q) = %v, want %v", tt.a, tt.b, result, tt.expected)
322
+
}
323
+
})
324
+
}
325
+
}
326
+
327
+
func TestMergeReplacements(t *testing.T) {
328
+
tests := []struct {
329
+
name string
330
+
input []Edit
331
+
expected []Edit
332
+
}{
333
+
{
334
+
name: "empty edits",
335
+
input: []Edit{},
336
+
expected: []Edit{},
337
+
},
338
+
{
339
+
name: "go.mod scenario - deletes followed by inserts with gap",
340
+
input: []Edit{
341
+
{Kind: Delete, AIndex: 17, BIndex: -1, Content: " github.com/charmbracelet/colorprofile v0.3.2 // indirect"},
342
+
{Kind: Delete, AIndex: 18, BIndex: -1, Content: " github.com/charmbracelet/lipgloss/v2"},
343
+
{Kind: Delete, AIndex: 19, BIndex: -1, Content: " github.com/charmbracelet/ultraviolet"},
344
+
{Kind: Delete, AIndex: 20, BIndex: -1, Content: " github.com/charmbracelet/x/ansi v0.10.1 // indirect"},
345
+
{Kind: Insert, AIndex: -1, BIndex: 23, Content: " github.com/aymanbagabas/go-udiff v0.3.1 // indirect"},
346
+
{Kind: Insert, AIndex: -1, BIndex: 24, Content: " github.com/charmbracelet/bubbletea v1.3.10"},
347
+
{Kind: Insert, AIndex: -1, BIndex: 25, Content: " github.com/charmbracelet/colorprofile v0.3.3 // indirect"},
348
+
{Kind: Insert, AIndex: -1, BIndex: 26, Content: " github.com/charmbracelet/lipgloss/v2"},
349
+
{Kind: Insert, AIndex: -1, BIndex: 27, Content: " github.com/charmbracelet/ultraviolet"},
350
+
{Kind: Insert, AIndex: -1, BIndex: 28, Content: " github.com/charmbracelet/x/ansi v0.10.3 // indirect"},
351
+
},
352
+
expected: []Edit{
353
+
{Kind: Replace, AIndex: 17, BIndex: 25, Content: " github.com/charmbracelet/colorprofile v0.3.2 // indirect", NewContent: " github.com/charmbracelet/colorprofile v0.3.3 // indirect"},
354
+
{Kind: Replace, AIndex: 18, BIndex: 26, Content: " github.com/charmbracelet/lipgloss/v2", NewContent: " github.com/charmbracelet/lipgloss/v2"},
355
+
{Kind: Replace, AIndex: 19, BIndex: 27, Content: " github.com/charmbracelet/ultraviolet", NewContent: " github.com/charmbracelet/ultraviolet"},
356
+
{Kind: Replace, AIndex: 20, BIndex: 28, Content: " github.com/charmbracelet/x/ansi v0.10.1 // indirect", NewContent: " github.com/charmbracelet/x/ansi v0.10.3 // indirect"},
357
+
{Kind: Insert, AIndex: -1, BIndex: 23, Content: " github.com/aymanbagabas/go-udiff v0.3.1 // indirect"},
358
+
{Kind: Insert, AIndex: -1, BIndex: 24, Content: " github.com/charmbracelet/bubbletea v1.3.10"},
359
+
},
360
+
},
361
+
{
362
+
name: "single edit",
363
+
input: []Edit{
364
+
{Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"},
365
+
},
366
+
expected: []Edit{
367
+
{Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"},
368
+
},
369
+
},
370
+
{
371
+
name: "merge similar delete and insert",
372
+
input: []Edit{
373
+
{Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"},
374
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "github.com/foo/bar v2.0.0"},
375
+
},
376
+
expected: []Edit{
377
+
{Kind: Replace, AIndex: 0, BIndex: 0, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"},
378
+
},
379
+
},
380
+
{
381
+
name: "don't merge dissimilar delete and insert",
382
+
input: []Edit{
383
+
{Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"},
384
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "import fmt"},
385
+
},
386
+
expected: []Edit{
387
+
{Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"},
388
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "import fmt"},
389
+
},
390
+
},
391
+
{
392
+
name: "merge insert and delete (reversed order)",
393
+
input: []Edit{
394
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "github.com/foo/bar v2.0.0"},
395
+
{Kind: Delete, AIndex: 0, BIndex: -1, Content: "github.com/foo/bar v1.0.0"},
396
+
},
397
+
expected: []Edit{
398
+
{Kind: Replace, AIndex: 0, BIndex: 0, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"},
399
+
},
400
+
},
401
+
{
402
+
name: "mixed operations with merge",
403
+
input: []Edit{
404
+
{Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"},
405
+
{Kind: Delete, AIndex: 1, BIndex: -1, Content: "github.com/foo/bar v1.0.0"},
406
+
{Kind: Insert, AIndex: -1, BIndex: 1, Content: "github.com/foo/bar v2.0.0"},
407
+
{Kind: Equal, AIndex: 2, BIndex: 2, Content: "line3"},
408
+
},
409
+
expected: []Edit{
410
+
{Kind: Equal, AIndex: 0, BIndex: 0, Content: "line1"},
411
+
{Kind: Replace, AIndex: 1, BIndex: 1, Content: "github.com/foo/bar v1.0.0", NewContent: "github.com/foo/bar v2.0.0"},
412
+
{Kind: Equal, AIndex: 2, BIndex: 2, Content: "line3"},
413
+
},
414
+
},
415
+
{
416
+
name: "multiple inserts and deletes without merge",
417
+
input: []Edit{
418
+
{Kind: Delete, AIndex: 0, BIndex: -1, Content: "deleted line 1"},
419
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "new content A"},
420
+
{Kind: Insert, AIndex: -1, BIndex: 1, Content: "new content B"},
421
+
},
422
+
expected: []Edit{
423
+
{Kind: Delete, AIndex: 0, BIndex: -1, Content: "deleted line 1"},
424
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "new content A"},
425
+
{Kind: Insert, AIndex: -1, BIndex: 1, Content: "new content B"},
426
+
},
427
+
},
428
+
}
429
+
430
+
for _, tt := range tests {
431
+
t.Run(tt.name, func(t *testing.T) {
432
+
result := MergeReplacements(tt.input)
433
+
434
+
if len(result) != len(tt.expected) {
435
+
t.Fatalf("expected %d edits, got %d", len(tt.expected), len(result))
436
+
}
437
+
438
+
for i := range result {
439
+
if result[i].Kind != tt.expected[i].Kind {
440
+
t.Errorf("edit %d: expected Kind %v, got %v", i, tt.expected[i].Kind, result[i].Kind)
441
+
}
442
+
if result[i].AIndex != tt.expected[i].AIndex {
443
+
t.Errorf("edit %d: expected AIndex %d, got %d", i, tt.expected[i].AIndex, result[i].AIndex)
444
+
}
445
+
if result[i].BIndex != tt.expected[i].BIndex {
446
+
t.Errorf("edit %d: expected BIndex %d, got %d", i, tt.expected[i].BIndex, result[i].BIndex)
447
+
}
448
+
if result[i].Content != tt.expected[i].Content {
449
+
t.Errorf("edit %d: expected Content %q, got %q", i, tt.expected[i].Content, result[i].Content)
450
+
}
451
+
if result[i].NewContent != tt.expected[i].NewContent {
452
+
t.Errorf("edit %d: expected NewContent %q, got %q", i, tt.expected[i].NewContent, result[i].NewContent)
453
+
}
454
+
}
455
+
})
456
+
}
457
+
}
+206
-28
internal/diff/format.go
+206
-28
internal/diff/format.go
···
5
5
"strings"
6
6
7
7
"github.com/charmbracelet/lipgloss"
8
+
"github.com/muesli/reflow/wordwrap"
8
9
"github.com/stormlightlabs/git-storm/internal/style"
9
10
)
10
11
11
12
const (
12
-
// Layout constants for side-by-side view
13
-
lineNumWidth = 4
14
-
gutterWidth = 3
15
-
minPaneWidth = 40
13
+
SymbolAdd = "┃" // addition
14
+
SymbolChange = "▎" // modification/change
15
+
SymbolDeleteLine = "_" // line removed
16
+
SymbolTopDelete = "‾" // deletion at top (overline)
17
+
SymbolChangeDelete = "~" // change + delete (hunk combined)
18
+
SymbolUntracked = "┆" // untracked lines/files
19
+
20
+
AsciiSymbolAdd = "|" // addition
21
+
AsciiSymbolChange = "|" // modification (same as add fallback)
22
+
AsciiSymbolDeleteLine = "-" // deletion line
23
+
AsciiSymbolTopDelete = "^" // “top delete” fallback
24
+
AsciiSymbolChangeDelete = "~" // change+delete still ~
25
+
AsciiSymbolUntracked = ":" // untracked fallback
26
+
27
+
lineNumWidth = 4
28
+
gutterWidth = 3
29
+
minPaneWidth = 40
30
+
contextLines = 3 // Lines to show before/after changes
31
+
minUnchangedToHide = 10 // Minimum unchanged lines before hiding
32
+
compressedIndicator = "⋮"
16
33
)
17
34
18
35
// SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting.
···
21
38
TerminalWidth int
22
39
// ShowLineNumbers controls whether line numbers are displayed
23
40
ShowLineNumbers bool
41
+
// Expanded controls whether to show all unchanged lines or compress them
42
+
Expanded bool
43
+
// EnableWordWrap enables word wrapping for long lines
44
+
EnableWordWrap bool
24
45
}
25
46
26
47
// Format renders the edits as a styled side-by-side diff string.
27
48
//
28
49
// The left pane shows the old content (deletions and unchanged lines).
29
50
// The right pane shows the new content (insertions and unchanged lines).
30
-
// Line numbers and color coding help visualize the changes.
31
51
func (f *SideBySideFormatter) Format(edits []Edit) string {
32
52
if len(edits) == 0 {
33
53
return style.StyleText.Render("No changes")
34
54
}
35
55
56
+
processedEdits := MergeReplacements(edits)
57
+
58
+
if !f.Expanded {
59
+
processedEdits = f.compressUnchangedBlocks(processedEdits)
60
+
}
61
+
36
62
paneWidth := f.calculatePaneWidth()
37
63
38
64
var sb strings.Builder
39
65
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true)
40
66
41
-
for _, edit := range edits {
67
+
for _, edit := range processedEdits {
42
68
left, right := f.renderEdit(edit, paneWidth)
43
69
44
70
if f.ShowLineNumbers {
···
69
95
}
70
96
71
97
availableWidth := f.TerminalWidth - usedWidth
98
+
if availableWidth < 0 {
99
+
availableWidth = 0
100
+
}
101
+
72
102
paneWidth := availableWidth / 2
73
103
74
104
if paneWidth < minPaneWidth {
75
-
paneWidth = minPaneWidth
105
+
totalNeeded := usedWidth + (2 * minPaneWidth)
106
+
if totalNeeded > f.TerminalWidth {
107
+
return paneWidth
108
+
}
109
+
return minPaneWidth
76
110
}
77
111
78
112
return paneWidth
···
82
116
func (f *SideBySideFormatter) renderEdit(edit Edit, paneWidth int) (left, right string) {
83
117
content := f.truncateContent(edit.Content, paneWidth)
84
118
119
+
if edit.AIndex == -2 && edit.BIndex == -2 {
120
+
compressedStyle := lipgloss.NewStyle().
121
+
Foreground(lipgloss.Color("#6C7A89")).
122
+
Faint(true).
123
+
Italic(true)
124
+
styled := f.padToWidth(compressedStyle.Render(content), paneWidth)
125
+
return styled, styled
126
+
}
127
+
85
128
switch edit.Kind {
86
129
case Equal:
87
-
// Show on both sides with neutral styling
88
-
leftStyled := style.StyleText.Width(paneWidth).Render(content)
89
-
rightStyled := style.StyleText.Width(paneWidth).Render(content)
130
+
leftStyled := f.padToWidth(style.StyleText.Render(content), paneWidth)
131
+
rightStyled := f.padToWidth(style.StyleText.Render(content), paneWidth)
90
132
return leftStyled, rightStyled
91
133
92
134
case Delete:
93
-
// Show on left in red, empty right
94
-
leftStyled := style.StyleRemoved.Width(paneWidth).Render(content)
95
-
rightStyled := lipgloss.NewStyle().Width(paneWidth).Render("")
135
+
leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth)
136
+
rightStyled := f.padToWidth("", paneWidth)
96
137
return leftStyled, rightStyled
97
138
98
139
case Insert:
99
-
// Empty left, show on right in green
100
-
leftStyled := lipgloss.NewStyle().Width(paneWidth).Render("")
101
-
rightStyled := style.StyleAdded.Width(paneWidth).Render(content)
140
+
leftStyled := f.padToWidth("", paneWidth)
141
+
rightStyled := f.padToWidth(style.StyleAdded.Render(content), paneWidth)
142
+
return leftStyled, rightStyled
143
+
144
+
case Replace:
145
+
newContent := f.truncateContent(edit.NewContent, paneWidth)
146
+
leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth)
147
+
rightStyled := f.padToWidth(style.StyleAdded.Render(newContent), paneWidth)
102
148
return leftStyled, rightStyled
103
149
104
150
default:
105
-
// Fallback for unknown edit kinds
106
-
return lipgloss.NewStyle().Width(paneWidth).Render(content),
107
-
lipgloss.NewStyle().Width(paneWidth).Render(content)
151
+
return f.padToWidth(content, paneWidth),
152
+
f.padToWidth(content, paneWidth)
108
153
}
109
154
}
110
155
156
+
// padToWidth pads a string with spaces to reach the target width.
157
+
// If the string exceeds the target width, it truncates it.
158
+
func (f *SideBySideFormatter) padToWidth(s string, targetWidth int) string {
159
+
currentWidth := lipgloss.Width(s)
160
+
161
+
if currentWidth > targetWidth {
162
+
return truncateToWidth(s, targetWidth)
163
+
}
164
+
165
+
if currentWidth == targetWidth {
166
+
return s
167
+
}
168
+
169
+
padding := strings.Repeat(" ", targetWidth-currentWidth)
170
+
return s + padding
171
+
}
172
+
111
173
// renderGutter creates the visual separator between left and right panes.
112
174
func (f *SideBySideFormatter) renderGutter(kind EditKind) string {
113
175
var symbol string
···
115
177
116
178
switch kind {
117
179
case Equal:
118
-
symbol = " │ "
180
+
symbol = " " + SymbolUntracked + " "
119
181
st = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89"))
120
182
case Delete:
121
-
symbol = " < "
183
+
symbol = " " + SymbolDeleteLine + " "
122
184
st = style.StyleRemoved
123
185
case Insert:
124
-
symbol = " > "
186
+
symbol = " " + SymbolAdd + " "
125
187
st = style.StyleAdded
188
+
case Replace:
189
+
symbol = " " + SymbolChange + " "
190
+
st = style.StyleChanged
126
191
default:
127
-
symbol = " │ "
192
+
symbol = " " + SymbolUntracked + " "
128
193
st = lipgloss.NewStyle()
129
194
}
130
195
···
139
204
return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1))
140
205
}
141
206
142
-
// truncateContent ensures content fits within the pane width.
207
+
// truncateContent ensures content fits within the pane width using proper display width.
143
208
func (f *SideBySideFormatter) truncateContent(content string, maxWidth int) string {
144
-
// Remove trailing whitespace but preserve leading indentation
145
209
content = strings.TrimRight(content, " \t\r\n")
146
210
147
-
if len(content) <= maxWidth {
211
+
if f.EnableWordWrap {
212
+
wrapped := wordwrap.String(content, maxWidth)
213
+
lines := strings.Split(wrapped, "\n")
214
+
if len(lines) > 0 {
215
+
return lines[0]
216
+
}
217
+
return wrapped
218
+
}
219
+
220
+
displayWidth := lipgloss.Width(content)
221
+
222
+
if displayWidth <= maxWidth {
148
223
return content
149
224
}
150
225
151
226
if maxWidth <= 3 {
152
-
return content[:maxWidth]
227
+
return truncateToWidth(content, maxWidth)
228
+
}
229
+
230
+
targetWidth := maxWidth - 3
231
+
truncated := truncateToWidth(content, targetWidth)
232
+
return truncated + "..."
233
+
}
234
+
235
+
// truncateToWidth truncates a string to a specific display width.
236
+
func truncateToWidth(s string, width int) string {
237
+
if width <= 0 {
238
+
return ""
239
+
}
240
+
241
+
var result strings.Builder
242
+
currentWidth := 0
243
+
244
+
for _, r := range s {
245
+
runeWidth := lipgloss.Width(string(r))
246
+
247
+
if currentWidth+runeWidth > width {
248
+
break
249
+
}
250
+
251
+
result.WriteRune(r)
252
+
currentWidth += runeWidth
253
+
}
254
+
255
+
return result.String()
256
+
}
257
+
258
+
// compressUnchangedBlocks compresses large blocks of unchanged lines.
259
+
//
260
+
// It keeps contextLines before and after changes, and replaces large
261
+
// blocks of unchanged lines with a single compressed indicator.
262
+
func (f *SideBySideFormatter) compressUnchangedBlocks(edits []Edit) []Edit {
263
+
if len(edits) == 0 {
264
+
return edits
265
+
}
266
+
267
+
var result []Edit
268
+
var unchangedRun []Edit
269
+
270
+
for i, edit := range edits {
271
+
if edit.Kind == Equal {
272
+
unchangedRun = append(unchangedRun, edit)
273
+
274
+
isLast := i == len(edits)-1
275
+
nextIsChanged := !isLast && edits[i+1].Kind != Equal
276
+
277
+
if isLast || nextIsChanged {
278
+
if len(unchangedRun) >= minUnchangedToHide {
279
+
for j := 0; j < contextLines && j < len(unchangedRun); j++ {
280
+
result = append(result, unchangedRun[j])
281
+
}
282
+
283
+
hiddenCount := len(unchangedRun) - (2 * contextLines)
284
+
if hiddenCount > 0 {
285
+
result = append(result, Edit{
286
+
Kind: Equal,
287
+
AIndex: -2,
288
+
BIndex: -2,
289
+
Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount),
290
+
})
291
+
}
292
+
293
+
start := max(len(unchangedRun)-contextLines, contextLines)
294
+
for j := start; j < len(unchangedRun); j++ {
295
+
result = append(result, unchangedRun[j])
296
+
}
297
+
} else {
298
+
result = append(result, unchangedRun...)
299
+
}
300
+
unchangedRun = nil
301
+
}
302
+
} else {
303
+
if len(unchangedRun) > 0 {
304
+
if len(unchangedRun) >= minUnchangedToHide {
305
+
for j := 0; j < contextLines && j < len(unchangedRun); j++ {
306
+
result = append(result, unchangedRun[j])
307
+
}
308
+
309
+
hiddenCount := len(unchangedRun) - (2 * contextLines)
310
+
if hiddenCount > 0 {
311
+
result = append(result, Edit{
312
+
Kind: Equal,
313
+
AIndex: -2,
314
+
BIndex: -2,
315
+
Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount),
316
+
})
317
+
}
318
+
319
+
start := max(len(unchangedRun)-contextLines, contextLines)
320
+
for j := start; j < len(unchangedRun); j++ {
321
+
result = append(result, unchangedRun[j])
322
+
}
323
+
} else {
324
+
result = append(result, unchangedRun...)
325
+
}
326
+
unchangedRun = nil
327
+
}
328
+
329
+
result = append(result, edit)
330
+
}
153
331
}
154
332
155
-
return content[:maxWidth-3] + "..."
333
+
return result
156
334
}
+157
internal/diff/format_compress_test.go
+157
internal/diff/format_compress_test.go
···
1
+
package diff
2
+
3
+
import (
4
+
"strings"
5
+
"testing"
6
+
)
7
+
8
+
func TestSideBySideFormatter_CompressUnchangedBlocks(t *testing.T) {
9
+
tests := []struct {
10
+
name string
11
+
edits []Edit
12
+
expectedCompressed, expectedTotal int
13
+
}{
14
+
{
15
+
name: "no compression for small unchanged blocks",
16
+
edits: makeEqualEdits(5),
17
+
expectedCompressed: 0,
18
+
expectedTotal: 5,
19
+
},
20
+
{
21
+
name: "compress large unchanged block",
22
+
edits: makeEqualEdits(20),
23
+
expectedCompressed: 1,
24
+
expectedTotal: 7,
25
+
},
26
+
{
27
+
name: "compress unchanged between changes",
28
+
edits: []Edit{
29
+
{Kind: Insert, AIndex: -1, BIndex: 0, Content: "new line"},
30
+
{Kind: Equal, AIndex: 0, BIndex: 1, Content: "unchanged 1"},
31
+
{Kind: Equal, AIndex: 1, BIndex: 2, Content: "unchanged 2"},
32
+
{Kind: Equal, AIndex: 2, BIndex: 3, Content: "unchanged 3"},
33
+
{Kind: Equal, AIndex: 3, BIndex: 4, Content: "unchanged 4"},
34
+
{Kind: Equal, AIndex: 4, BIndex: 5, Content: "unchanged 5"},
35
+
{Kind: Equal, AIndex: 5, BIndex: 6, Content: "unchanged 6"},
36
+
{Kind: Equal, AIndex: 6, BIndex: 7, Content: "unchanged 7"},
37
+
{Kind: Equal, AIndex: 7, BIndex: 8, Content: "unchanged 8"},
38
+
{Kind: Equal, AIndex: 8, BIndex: 9, Content: "unchanged 9"},
39
+
{Kind: Equal, AIndex: 9, BIndex: 10, Content: "unchanged 10"},
40
+
{Kind: Equal, AIndex: 10, BIndex: 11, Content: "unchanged 11"},
41
+
{Kind: Equal, AIndex: 11, BIndex: 12, Content: "unchanged 12"},
42
+
{Kind: Equal, AIndex: 12, BIndex: 13, Content: "unchanged 13"},
43
+
{Kind: Delete, AIndex: 13, BIndex: -1, Content: "removed line"},
44
+
},
45
+
expectedCompressed: 1,
46
+
expectedTotal: 9,
47
+
},
48
+
}
49
+
50
+
for _, tt := range tests {
51
+
t.Run(tt.name, func(t *testing.T) {
52
+
formatter := &SideBySideFormatter{}
53
+
54
+
result := formatter.compressUnchangedBlocks(tt.edits)
55
+
56
+
if len(result) != tt.expectedTotal {
57
+
t.Errorf("Expected %d total edits after compression, got %d", tt.expectedTotal, len(result))
58
+
}
59
+
60
+
compressed := countCompressedBlocks(result)
61
+
if compressed != tt.expectedCompressed {
62
+
t.Errorf("Expected %d compressed blocks, got %d", tt.expectedCompressed, compressed)
63
+
}
64
+
})
65
+
}
66
+
}
67
+
68
+
func TestSideBySideFormatter_Expanded(t *testing.T) {
69
+
edits := makeEqualEdits(20)
70
+
71
+
compressedFormatter := &SideBySideFormatter{
72
+
TerminalWidth: 100,
73
+
ShowLineNumbers: true,
74
+
Expanded: false,
75
+
}
76
+
77
+
expandedFormatter := &SideBySideFormatter{
78
+
TerminalWidth: 100,
79
+
ShowLineNumbers: true,
80
+
Expanded: true,
81
+
}
82
+
83
+
compressedOutput := compressedFormatter.Format(edits)
84
+
expandedOutput := expandedFormatter.Format(edits)
85
+
86
+
compressedLines := strings.Split(compressedOutput, "\n")
87
+
expandedLines := strings.Split(expandedOutput, "\n")
88
+
89
+
if len(expandedLines) <= len(compressedLines) {
90
+
t.Errorf("Expanded output (%d lines) should have more lines than compressed (%d lines)",
91
+
len(expandedLines), len(compressedLines))
92
+
}
93
+
94
+
if !strings.Contains(compressedOutput, compressedIndicator) {
95
+
t.Error("Compressed output should contain compression indicator")
96
+
}
97
+
98
+
if strings.Contains(expandedOutput, compressedIndicator) {
99
+
t.Error("Expanded output should not contain compression indicator")
100
+
}
101
+
}
102
+
103
+
func TestSideBySideFormatter_CompressedIndicatorStyling(t *testing.T) {
104
+
formatter := &SideBySideFormatter{
105
+
TerminalWidth: 100,
106
+
ShowLineNumbers: true,
107
+
Expanded: false,
108
+
}
109
+
110
+
edits := makeEqualEdits(20)
111
+
output := formatter.Format(edits)
112
+
113
+
if !strings.Contains(output, "unchanged lines") {
114
+
t.Error("Compressed output should mention 'unchanged lines'")
115
+
}
116
+
}
117
+
118
+
func TestMultipleCompressedBlocks(t *testing.T) {
119
+
formatter := &SideBySideFormatter{}
120
+
121
+
edits := []Edit{}
122
+
edits = append(edits, makeEqualEdits(20)...)
123
+
edits = append(edits, Edit{Kind: Insert, AIndex: -1, BIndex: 0, Content: "change 1"})
124
+
edits = append(edits, makeEqualEdits(20)...)
125
+
edits = append(edits, Edit{Kind: Delete, AIndex: 0, BIndex: -1, Content: "change 2"})
126
+
edits = append(edits, makeEqualEdits(20)...)
127
+
128
+
result := formatter.compressUnchangedBlocks(edits)
129
+
130
+
compressed := countCompressedBlocks(result)
131
+
if compressed != 3 {
132
+
t.Errorf("Expected 3 compressed blocks, got %d", compressed)
133
+
}
134
+
}
135
+
136
+
func makeEqualEdits(count int) []Edit {
137
+
edits := make([]Edit, count)
138
+
for i := range count {
139
+
edits[i] = Edit{
140
+
Kind: Equal,
141
+
AIndex: i,
142
+
BIndex: i,
143
+
Content: "unchanged line",
144
+
}
145
+
}
146
+
return edits
147
+
}
148
+
149
+
func countCompressedBlocks(edits []Edit) int {
150
+
count := 0
151
+
for _, edit := range edits {
152
+
if edit.AIndex == -2 && edit.BIndex == -2 {
153
+
count++
154
+
}
155
+
}
156
+
return count
157
+
}
+70
-4
internal/diff/format_test.go
+70
-4
internal/diff/format_test.go
···
3
3
import (
4
4
"strings"
5
5
"testing"
6
+
7
+
"github.com/charmbracelet/lipgloss"
6
8
)
7
9
8
10
func TestSideBySideFormatter_Format(t *testing.T) {
···
37
39
},
38
40
width: 80,
39
41
expect: func(output string) bool {
40
-
return strings.Contains(output, "new line") && strings.Contains(output, ">")
42
+
return strings.Contains(output, "new line") && strings.Contains(output, SymbolAdd)
41
43
},
42
44
},
43
45
{
···
47
49
},
48
50
width: 80,
49
51
expect: func(output string) bool {
50
-
return strings.Contains(output, "old line") && strings.Contains(output, "<")
52
+
return strings.Contains(output, "old line") && strings.Contains(output, SymbolDeleteLine)
51
53
},
52
54
},
53
55
{
···
100
102
name: "narrow terminal",
101
103
terminalWidth: 60,
102
104
showLineNumbers: true,
103
-
minExpected: minPaneWidth,
105
+
minExpected: 20,
104
106
},
105
107
{
106
108
name: "without line numbers",
···
122
124
if paneWidth < tt.minExpected {
123
125
t.Errorf("calculatePaneWidth() = %d, expected at least %d", paneWidth, tt.minExpected)
124
126
}
127
+
128
+
usedWidth := gutterWidth
129
+
if tt.showLineNumbers {
130
+
usedWidth += 2 * lineNumWidth
131
+
}
132
+
totalWidth := usedWidth + (2 * paneWidth)
133
+
if totalWidth > tt.terminalWidth {
134
+
t.Errorf("Total width %d exceeds terminal width %d (paneWidth=%d)", totalWidth, tt.terminalWidth, paneWidth)
135
+
}
136
+
})
137
+
}
138
+
}
139
+
140
+
func TestPadToWidth(t *testing.T) {
141
+
formatter := &SideBySideFormatter{}
142
+
143
+
tests := []struct {
144
+
name string
145
+
input string
146
+
targetWidth int
147
+
}{
148
+
{
149
+
name: "short string gets padded",
150
+
input: "hello",
151
+
targetWidth: 10,
152
+
},
153
+
{
154
+
name: "exact width unchanged",
155
+
input: "hello world",
156
+
targetWidth: 11,
157
+
},
158
+
{
159
+
name: "long string gets truncated",
160
+
input: "this is a very long string that exceeds the target width",
161
+
targetWidth: 20,
162
+
},
163
+
}
164
+
165
+
for _, tt := range tests {
166
+
t.Run(tt.name, func(t *testing.T) {
167
+
result := formatter.padToWidth(tt.input, tt.targetWidth)
168
+
169
+
resultWidth := lipgloss.Width(result)
170
+
if resultWidth != tt.targetWidth {
171
+
t.Errorf("padToWidth() width = %d, expected exactly %d", resultWidth, tt.targetWidth)
172
+
}
125
173
})
126
174
}
127
175
}
···
165
213
maxWidth: 10,
166
214
expected: "hello",
167
215
},
216
+
{
217
+
name: "very long line",
218
+
content: "github.com/charmbracelet/x/ansi v0.10.3 h1:3WoV9XN8uMEnFRZZ+vBPRy59TaI",
219
+
maxWidth: 40,
220
+
expected: "",
221
+
},
168
222
}
169
223
170
224
for _, tt := range tests {
171
225
t.Run(tt.name, func(t *testing.T) {
172
226
result := formatter.truncateContent(tt.content, tt.maxWidth)
173
-
if result != tt.expected {
227
+
228
+
displayWidth := lipgloss.Width(result)
229
+
if displayWidth > tt.maxWidth {
230
+
t.Errorf("truncateContent() display width = %d, exceeds max %d", displayWidth, tt.maxWidth)
231
+
}
232
+
233
+
if tt.expected != "" && result != tt.expected {
174
234
t.Errorf("truncateContent() = %q, expected %q", result, tt.expected)
235
+
}
236
+
237
+
if lipgloss.Width(tt.content) > tt.maxWidth && tt.maxWidth > 3 {
238
+
if !strings.HasSuffix(result, "...") {
239
+
t.Errorf("truncateContent() should end with '...' for long content")
240
+
}
175
241
}
176
242
})
177
243
}
+213
-1
internal/ui/ui.go
+213
-1
internal/ui/ui.go
···
5
5
"strings"
6
6
7
7
"github.com/charmbracelet/bubbles/key"
8
+
"github.com/charmbracelet/bubbles/paginator"
8
9
"github.com/charmbracelet/bubbles/viewport"
9
10
tea "github.com/charmbracelet/bubbletea"
10
11
"github.com/charmbracelet/lipgloss"
11
12
"github.com/stormlightlabs/git-storm/internal/diff"
12
13
"github.com/stormlightlabs/git-storm/internal/style"
13
14
)
15
+
16
+
// FileDiff represents a diff for a single file.
17
+
type FileDiff struct {
18
+
Edits []diff.Edit
19
+
OldPath string
20
+
NewPath string
21
+
}
14
22
15
23
// DiffModel holds the state for the side-by-side diff viewer.
16
24
type DiffModel struct {
···
82
90
83
91
content := formatter.Format(edits)
84
92
85
-
vp := viewport.New(terminalWidth, terminalHeight-2) // Reserve space for header
93
+
vp := viewport.New(terminalWidth, terminalHeight-2)
86
94
vp.SetContent(content)
87
95
88
96
return DiffModel{
···
201
209
helpText + strings.Repeat(" ", padding) + scrollInfo,
202
210
)
203
211
}
212
+
213
+
// MultiFileDiffModel holds the state for viewing diffs across multiple files with pagination.
214
+
type MultiFileDiffModel struct {
215
+
files []FileDiff
216
+
paginator paginator.Model
217
+
viewport viewport.Model
218
+
ready bool
219
+
width int
220
+
height int
221
+
expanded bool // Controls whether unchanged blocks are compressed
222
+
}
223
+
224
+
// NewMultiFileDiffModel creates a new multi-file diff viewer with pagination.
225
+
func NewMultiFileDiffModel(files []FileDiff, expanded bool) MultiFileDiffModel {
226
+
p := paginator.New()
227
+
p.Type = paginator.Dots
228
+
p.PerPage = 1
229
+
p.SetTotalPages(len(files))
230
+
p.ActiveDot = lipgloss.NewStyle().Foreground(style.AccentBlue).Render("•")
231
+
p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("•")
232
+
233
+
model := MultiFileDiffModel{
234
+
files: files,
235
+
paginator: p,
236
+
ready: false,
237
+
expanded: expanded,
238
+
}
239
+
240
+
return model
241
+
}
242
+
243
+
// Init initializes the multi-file diff model.
244
+
func (m MultiFileDiffModel) Init() tea.Cmd {
245
+
return nil
246
+
}
247
+
248
+
// Update handles messages and updates the multi-file diff model state.
249
+
func (m MultiFileDiffModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
250
+
var cmds []tea.Cmd
251
+
var cmd tea.Cmd
252
+
253
+
switch msg := msg.(type) {
254
+
case tea.KeyMsg:
255
+
switch {
256
+
case key.Matches(msg, keys.Quit):
257
+
return m, tea.Quit
258
+
259
+
case key.Matches(msg, key.NewBinding(key.WithKeys("e"))):
260
+
// Toggle expanded/compressed view
261
+
m.expanded = !m.expanded
262
+
m.updateViewport()
263
+
264
+
case key.Matches(msg, key.NewBinding(key.WithKeys("left", "h"))):
265
+
m.paginator.PrevPage()
266
+
m.updateViewport()
267
+
m.viewport.GotoTop()
268
+
269
+
case key.Matches(msg, key.NewBinding(key.WithKeys("right", "l"))):
270
+
m.paginator.NextPage()
271
+
m.updateViewport()
272
+
m.viewport.GotoTop()
273
+
274
+
case key.Matches(msg, keys.Up):
275
+
m.viewport.LineUp(1)
276
+
277
+
case key.Matches(msg, keys.Down):
278
+
m.viewport.LineDown(1)
279
+
280
+
case key.Matches(msg, keys.PageUp):
281
+
m.viewport.ViewUp()
282
+
283
+
case key.Matches(msg, keys.PageDown):
284
+
m.viewport.ViewDown()
285
+
286
+
case key.Matches(msg, keys.HalfUp):
287
+
m.viewport.HalfViewUp()
288
+
289
+
case key.Matches(msg, keys.HalfDown):
290
+
m.viewport.HalfViewDown()
291
+
292
+
case key.Matches(msg, keys.Top):
293
+
m.viewport.GotoTop()
294
+
295
+
case key.Matches(msg, keys.Bottom):
296
+
m.viewport.GotoBottom()
297
+
}
298
+
299
+
case tea.WindowSizeMsg:
300
+
m.width = msg.Width
301
+
m.height = msg.Height
302
+
303
+
if !m.ready {
304
+
m.viewport = viewport.New(msg.Width, msg.Height-4)
305
+
m.ready = true
306
+
} else {
307
+
m.viewport.Width = msg.Width
308
+
m.viewport.Height = msg.Height - 4
309
+
}
310
+
311
+
m.updateViewport()
312
+
}
313
+
314
+
m.viewport, cmd = m.viewport.Update(msg)
315
+
cmds = append(cmds, cmd)
316
+
317
+
return m, tea.Batch(cmds...)
318
+
}
319
+
320
+
// View renders the current view of the multi-file diff viewer.
321
+
func (m MultiFileDiffModel) View() string {
322
+
if !m.ready || len(m.files) == 0 {
323
+
return "\n No files to display"
324
+
}
325
+
326
+
header := m.renderMultiFileHeader()
327
+
footer := m.renderMultiFileFooter()
328
+
paginatorView := m.renderPaginator()
329
+
330
+
return fmt.Sprintf("%s\n%s\n%s\n%s", header, m.viewport.View(), paginatorView, footer)
331
+
}
332
+
333
+
// updateViewport updates the viewport content to show the current file.
334
+
func (m *MultiFileDiffModel) updateViewport() {
335
+
if len(m.files) == 0 {
336
+
return
337
+
}
338
+
339
+
currentFile := m.files[m.paginator.Page]
340
+
formatter := &diff.SideBySideFormatter{
341
+
TerminalWidth: m.width,
342
+
ShowLineNumbers: true,
343
+
Expanded: m.expanded,
344
+
EnableWordWrap: false,
345
+
}
346
+
347
+
content := formatter.Format(currentFile.Edits)
348
+
m.viewport.SetContent(content)
349
+
}
350
+
351
+
// renderMultiFileHeader creates the header showing current file paths.
352
+
func (m MultiFileDiffModel) renderMultiFileHeader() string {
353
+
if len(m.files) == 0 {
354
+
return ""
355
+
}
356
+
357
+
currentFile := m.files[m.paginator.Page]
358
+
359
+
headerStyle := lipgloss.NewStyle().
360
+
Foreground(style.AccentBlue).
361
+
Bold(true).
362
+
Padding(0, 1)
363
+
364
+
oldLabel := lipgloss.NewStyle().Foreground(style.RemovedColor).Render("−")
365
+
newLabel := lipgloss.NewStyle().Foreground(style.AddedColor).Render("+")
366
+
367
+
fileIndicator := fmt.Sprintf("[%d/%d]", m.paginator.Page+1, len(m.files))
368
+
369
+
return headerStyle.Render(
370
+
fmt.Sprintf("%s %s %s %s %s", fileIndicator, oldLabel, currentFile.OldPath, newLabel, currentFile.NewPath),
371
+
)
372
+
}
373
+
374
+
// renderPaginator renders the pagination dots.
375
+
func (m MultiFileDiffModel) renderPaginator() string {
376
+
if len(m.files) <= 1 {
377
+
return ""
378
+
}
379
+
380
+
return lipgloss.NewStyle().
381
+
Foreground(lipgloss.Color("#6C7A89")).
382
+
Padding(0, 1).
383
+
Render(m.paginator.View())
384
+
}
385
+
386
+
// renderMultiFileFooter creates the footer with help text and scroll position.
387
+
func (m MultiFileDiffModel) renderMultiFileFooter() string {
388
+
footerStyle := lipgloss.NewStyle().
389
+
Foreground(lipgloss.Color("#6C7A89")).
390
+
Faint(true).
391
+
Padding(0, 1)
392
+
393
+
expandedIndicator := "compressed"
394
+
if m.expanded {
395
+
expandedIndicator = "expanded"
396
+
}
397
+
398
+
helpText := fmt.Sprintf("↑/↓: scroll • h/l: files • e: %s • q: quit", expandedIndicator)
399
+
400
+
scrollPercent := m.viewport.ScrollPercent()
401
+
scrollInfo := fmt.Sprintf("%.0f%%", scrollPercent*100)
402
+
403
+
totalWidth := m.width
404
+
helpWidth := lipgloss.Width(helpText)
405
+
scrollWidth := lipgloss.Width(scrollInfo)
406
+
padding := totalWidth - helpWidth - scrollWidth - 2
407
+
408
+
if padding < 0 {
409
+
padding = 0
410
+
}
411
+
412
+
return footerStyle.Render(
413
+
helpText + strings.Repeat(" ", padding) + scrollInfo,
414
+
)
415
+
}
+199
-4
internal/ui/ui_test.go
+199
-4
internal/ui/ui_test.go
···
60
60
61
61
tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 20))
62
62
63
-
// Test down movement
64
63
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
65
64
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
66
65
return len(bts) > 0
67
66
})
68
67
69
-
// Test up movement
70
68
tm.Send(tea.KeyMsg{Type: tea.KeyUp})
71
69
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
72
70
return len(bts) > 0
73
71
})
74
72
75
-
// Test quit
76
73
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
77
74
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
78
75
}
···
83
80
}
84
81
85
82
quitKeys := []tea.KeyType{
86
-
tea.KeyRunes, // 'q'
83
+
tea.KeyRunes,
87
84
tea.KeyEsc,
88
85
tea.KeyCtrlC,
89
86
}
···
135
132
t.Error("Footer should contain help text about quitting")
136
133
}
137
134
}
135
+
136
+
func TestMultiFileDiffModel_Init(t *testing.T) {
137
+
files := []FileDiff{
138
+
{
139
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}},
140
+
OldPath: "old/file1.go",
141
+
NewPath: "new/file1.go",
142
+
},
143
+
}
144
+
145
+
model := NewMultiFileDiffModel(files, false)
146
+
147
+
cmd := model.Init()
148
+
if cmd != nil {
149
+
t.Errorf("Init() should return nil, got %v", cmd)
150
+
}
151
+
}
152
+
153
+
func TestMultiFileDiffModel_View(t *testing.T) {
154
+
files := []FileDiff{
155
+
{
156
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "line 1"}},
157
+
OldPath: "old/file1.go",
158
+
NewPath: "new/file1.go",
159
+
},
160
+
{
161
+
Edits: []diff.Edit{{Kind: diff.Insert, AIndex: -1, BIndex: 0, Content: "line 2"}},
162
+
OldPath: "old/file2.go",
163
+
NewPath: "new/file2.go",
164
+
},
165
+
}
166
+
167
+
model := NewMultiFileDiffModel(files, false)
168
+
169
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
170
+
model = updated.(MultiFileDiffModel)
171
+
172
+
view := model.View()
173
+
174
+
if !strings.Contains(view, "file1.go") {
175
+
t.Error("View should contain first file path")
176
+
}
177
+
if !strings.Contains(view, "[1/2]") {
178
+
t.Error("View should contain file indicator [1/2]")
179
+
}
180
+
}
181
+
182
+
func TestMultiFileDiffModel_Pagination(t *testing.T) {
183
+
files := []FileDiff{
184
+
{
185
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "file 1"}},
186
+
OldPath: "old/file1.go",
187
+
NewPath: "new/file1.go",
188
+
},
189
+
{
190
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "file 2"}},
191
+
OldPath: "old/file2.go",
192
+
NewPath: "new/file2.go",
193
+
},
194
+
{
195
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "file 3"}},
196
+
OldPath: "old/file3.go",
197
+
NewPath: "new/file3.go",
198
+
},
199
+
}
200
+
201
+
model := NewMultiFileDiffModel(files, false)
202
+
203
+
tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 24))
204
+
205
+
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
206
+
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
207
+
return len(bts) > 0
208
+
})
209
+
210
+
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
211
+
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
212
+
return len(bts) > 0
213
+
})
214
+
215
+
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
216
+
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
217
+
}
218
+
219
+
func TestMultiFileDiffModel_EmptyFiles(t *testing.T) {
220
+
files := []FileDiff{}
221
+
222
+
model := NewMultiFileDiffModel(files, false)
223
+
224
+
view := model.View()
225
+
226
+
if !strings.Contains(view, "No files") {
227
+
t.Error("View should indicate no files to display")
228
+
}
229
+
}
230
+
231
+
func TestMultiFileDiffModel_SingleFile(t *testing.T) {
232
+
files := []FileDiff{
233
+
{
234
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}},
235
+
OldPath: "old/single.go",
236
+
NewPath: "new/single.go",
237
+
},
238
+
}
239
+
240
+
model := NewMultiFileDiffModel(files, false)
241
+
242
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
243
+
model = updated.(MultiFileDiffModel)
244
+
245
+
view := model.View()
246
+
247
+
if !strings.Contains(view, "single.go") {
248
+
t.Error("View should contain file path")
249
+
}
250
+
if !strings.Contains(view, "[1/1]") {
251
+
t.Error("View should show [1/1] for single file")
252
+
}
253
+
}
254
+
255
+
func TestMultiFileDiffModel_UpdateViewport(t *testing.T) {
256
+
files := []FileDiff{
257
+
{
258
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "content 1"}},
259
+
OldPath: "file1.go",
260
+
NewPath: "file1.go",
261
+
},
262
+
{
263
+
Edits: []diff.Edit{{Kind: diff.Insert, AIndex: -1, BIndex: 0, Content: "content 2"}},
264
+
OldPath: "file2.go",
265
+
NewPath: "file2.go",
266
+
},
267
+
}
268
+
269
+
model := NewMultiFileDiffModel(files, false)
270
+
271
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
272
+
model = updated.(MultiFileDiffModel)
273
+
274
+
initialView := model.View()
275
+
if !strings.Contains(initialView, "content 1") {
276
+
t.Error("Initial view should contain content from first file")
277
+
}
278
+
279
+
model.paginator.NextPage()
280
+
model.updateViewport()
281
+
282
+
updatedView := model.View()
283
+
if !strings.Contains(updatedView, "file2.go") {
284
+
t.Error("Updated view should show second file path")
285
+
}
286
+
}
287
+
288
+
func TestMultiFileDiffModel_RenderHeader(t *testing.T) {
289
+
files := []FileDiff{
290
+
{
291
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}},
292
+
OldPath: "old/test.go",
293
+
NewPath: "new/test.go",
294
+
},
295
+
}
296
+
297
+
model := NewMultiFileDiffModel(files, false)
298
+
header := model.renderMultiFileHeader()
299
+
300
+
if !strings.Contains(header, "old/test.go") {
301
+
t.Error("Header should contain old file path")
302
+
}
303
+
if !strings.Contains(header, "new/test.go") {
304
+
t.Error("Header should contain new file path")
305
+
}
306
+
if !strings.Contains(header, "[1/1]") {
307
+
t.Error("Header should contain file indicator")
308
+
}
309
+
}
310
+
311
+
func TestMultiFileDiffModel_RenderFooter(t *testing.T) {
312
+
files := []FileDiff{
313
+
{
314
+
Edits: []diff.Edit{{Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}},
315
+
OldPath: "test.go",
316
+
NewPath: "test.go",
317
+
},
318
+
}
319
+
320
+
model := NewMultiFileDiffModel(files, false)
321
+
footer := model.renderMultiFileFooter()
322
+
323
+
if !strings.Contains(footer, "h/l") {
324
+
t.Error("Footer should contain navigation help for h/l keys")
325
+
}
326
+
if !strings.Contains(footer, "scroll") {
327
+
t.Error("Footer should contain scroll help")
328
+
}
329
+
if !strings.Contains(footer, "quit") {
330
+
t.Error("Footer should contain quit help")
331
+
}
332
+
}
+119
-22
main.go
cmd/main.go
+119
-22
main.go
cmd/main.go
···
42
42
43
43
const versionString string = "0.1.0-dev"
44
44
45
+
// parseRefArgs parses command arguments to extract from/to refs.
46
+
// Supports both "from..to" and "from to" syntax.
47
+
func parseRefArgs(args []string) (from, to string) {
48
+
if len(args) == 1 {
49
+
parts := strings.Split(args[0], "..")
50
+
if len(parts) == 2 {
51
+
return parts[0], parts[1]
52
+
}
53
+
return args[0], "HEAD"
54
+
}
55
+
return args[0], args[1]
56
+
}
57
+
45
58
// runDiff executes the diff command by reading file contents from two git refs and launching the TUI.
46
-
func runDiff(fromRef, toRef, filePath string) error {
59
+
func runDiff(fromRef, toRef, filePath string, expanded bool) error {
47
60
repo, err := git.PlainOpen(repoPath)
48
61
if err != nil {
49
62
return fmt.Errorf("failed to open repository: %w", err)
50
63
}
51
64
52
-
oldContent, err := getFileContent(repo, fromRef, filePath)
53
-
if err != nil {
54
-
return fmt.Errorf("failed to read %s from %s: %w", filePath, fromRef, err)
65
+
var filesToDiff []string
66
+
if filePath != "" {
67
+
filesToDiff = []string{filePath}
68
+
} else {
69
+
filesToDiff, err = getChangedFiles(repo, fromRef, toRef)
70
+
if err != nil {
71
+
return fmt.Errorf("failed to get changed files: %w", err)
72
+
}
73
+
if len(filesToDiff) == 0 {
74
+
fmt.Println("No files changed between", fromRef, "and", toRef)
75
+
return nil
76
+
}
55
77
}
56
78
57
-
newContent, err := getFileContent(repo, toRef, filePath)
58
-
if err != nil {
59
-
return fmt.Errorf("failed to read %s from %s: %w", filePath, toRef, err)
60
-
}
79
+
allDiffs := make([]ui.FileDiff, 0, len(filesToDiff))
80
+
81
+
for _, file := range filesToDiff {
82
+
oldContent, err := getFileContent(repo, fromRef, file)
83
+
if err != nil {
84
+
oldContent = ""
85
+
}
61
86
62
-
oldLines := strings.Split(oldContent, "\n")
63
-
newLines := strings.Split(newContent, "\n")
87
+
newContent, err := getFileContent(repo, toRef, file)
88
+
if err != nil {
89
+
newContent = ""
90
+
}
91
+
92
+
oldLines := strings.Split(oldContent, "\n")
93
+
newLines := strings.Split(newContent, "\n")
94
+
95
+
myers := &diff.Myers{}
96
+
edits, err := myers.Compute(oldLines, newLines)
97
+
if err != nil {
98
+
return fmt.Errorf("diff computation failed for %s: %w", file, err)
99
+
}
64
100
65
-
myers := &diff.Myers{}
66
-
edits, err := myers.Compute(oldLines, newLines)
67
-
if err != nil {
68
-
return fmt.Errorf("diff computation failed: %w", err)
101
+
allDiffs = append(allDiffs, ui.FileDiff{
102
+
Edits: edits,
103
+
OldPath: fromRef + ":" + file,
104
+
NewPath: toRef + ":" + file,
105
+
})
69
106
}
70
107
71
-
model := ui.NewDiffModel(edits, fromRef+":"+filePath, toRef+":"+filePath, 120, 30)
108
+
model := ui.NewMultiFileDiffModel(allDiffs, expanded)
72
109
73
110
p := tea.NewProgram(model, tea.WithAltScreen())
74
111
if _, err := p.Run(); err != nil {
···
108
145
return content, nil
109
146
}
110
147
148
+
// getChangedFiles returns the list of files that changed between two commits.
149
+
func getChangedFiles(repo *git.Repository, fromRef, toRef string) ([]string, error) {
150
+
fromHash, err := repo.ResolveRevision(plumbing.Revision(fromRef))
151
+
if err != nil {
152
+
return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err)
153
+
}
154
+
155
+
toHash, err := repo.ResolveRevision(plumbing.Revision(toRef))
156
+
if err != nil {
157
+
return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err)
158
+
}
159
+
160
+
fromCommit, err := repo.CommitObject(*fromHash)
161
+
if err != nil {
162
+
return nil, fmt.Errorf("failed to get commit %s: %w", fromRef, err)
163
+
}
164
+
165
+
toCommit, err := repo.CommitObject(*toHash)
166
+
if err != nil {
167
+
return nil, fmt.Errorf("failed to get commit %s: %w", toRef, err)
168
+
}
169
+
170
+
fromTree, err := fromCommit.Tree()
171
+
if err != nil {
172
+
return nil, fmt.Errorf("failed to get tree for %s: %w", fromRef, err)
173
+
}
174
+
175
+
toTree, err := toCommit.Tree()
176
+
if err != nil {
177
+
return nil, fmt.Errorf("failed to get tree for %s: %w", toRef, err)
178
+
}
179
+
180
+
changes, err := fromTree.Diff(toTree)
181
+
if err != nil {
182
+
return nil, fmt.Errorf("failed to compute diff: %w", err)
183
+
}
184
+
185
+
files := make([]string, 0, len(changes))
186
+
for _, change := range changes {
187
+
if change.To.Name != "" {
188
+
files = append(files, change.To.Name)
189
+
} else {
190
+
files = append(files, change.From.Name)
191
+
}
192
+
}
193
+
194
+
return files, nil
195
+
}
196
+
111
197
func versionCmd() *cobra.Command {
112
198
return &cobra.Command{
113
199
Use: "version",
···
121
207
122
208
func diffCmd() *cobra.Command {
123
209
var filePath string
210
+
var expanded bool
124
211
125
212
c := &cobra.Command{
126
-
Use: "diff <from> <to>",
213
+
Use: "diff <from>..<to> | diff <from> <to>",
127
214
Short: "Show a line-based diff between two commits or tags",
128
-
Long: `Displays an inline diff (added/removed/unchanged lines) between two refs
129
-
using the built-in diff engine.`,
130
-
Args: cobra.ExactArgs(2),
215
+
Long: `Displays an inline diff (added/removed/unchanged lines) between two refs.
216
+
217
+
Supports multiple input formats:
218
+
- Range syntax: commit1..commit2
219
+
- Separate args: commit1 commit2
220
+
- Truncated hashes: 7de6f6d..18363c2
221
+
222
+
If --file is not specified, shows all changed files with pagination.
223
+
224
+
By default, large blocks of unchanged lines are compressed. Use --expanded
225
+
to show all lines. You can also toggle this with 'e' in the TUI.`,
226
+
Args: cobra.RangeArgs(1, 2),
131
227
RunE: func(cmd *cobra.Command, args []string) error {
132
-
return runDiff(args[0], args[1], filePath)
228
+
from, to := parseRefArgs(args)
229
+
return runDiff(from, to, filePath, expanded)
133
230
},
134
231
}
135
232
136
-
c.Flags().StringVarP(&filePath, "file", "f", "", "File path to diff (required)")
137
-
c.MarkFlagRequired("file")
233
+
c.Flags().StringVarP(&filePath, "file", "f", "", "Specific file to diff (optional, shows all files if omitted)")
234
+
c.Flags().BoolVarP(&expanded, "expanded", "e", false, "Show all unchanged lines (disable compression)")
138
235
139
236
return c
140
237
}