package gitx
import (
"strings"
"testing"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/format/diff"
)
// --- fakes implementing the go-git diff interfaces for pure-function tests ---
type fakeFile struct{ path string }
func (f fakeFile) Hash() plumbing.Hash { return plumbing.ZeroHash }
func (f fakeFile) Mode() filemode.FileMode { return filemode.Regular }
func (f fakeFile) Path() string { return f.path }
type fakeChunk struct {
content string
op diff.Operation
}
func (c fakeChunk) Content() string { return c.content }
func (c fakeChunk) Type() diff.Operation { return c.op }
type fakeFilePatch struct {
from, to diff.File
binary bool
chunks []diff.Chunk
}
func (f fakeFilePatch) IsBinary() bool { return f.binary }
func (f fakeFilePatch) Files() (diff.File, diff.File) { return f.from, f.to }
func (f fakeFilePatch) Chunks() []diff.Chunk { return f.chunks }
func file(p string) diff.File { return fakeFile{path: p} }
func TestMapFilePatches(t *testing.T) {
add := func(s string) diff.Chunk { return fakeChunk{content: s, op: diff.Add} }
del := func(s string) diff.Chunk { return fakeChunk{content: s, op: diff.Delete} }
eq := func(s string) diff.Chunk { return fakeChunk{content: s, op: diff.Equal} }
tests := []struct {
name string
fp fakeFilePatch
want FileChange
}{
{
"added",
fakeFilePatch{to: file("new.txt"), chunks: []diff.Chunk{add("a\nb\n")}},
FileChange{Status: "A", Path: "new.txt", Additions: 2},
},
{
"deleted",
fakeFilePatch{from: file("gone.txt"), chunks: []diff.Chunk{del("x\n")}},
FileChange{Status: "D", Path: "gone.txt", Deletions: 1},
},
{
"modified",
fakeFilePatch{
from: file("a.txt"),
to: file("a.txt"),
chunks: []diff.Chunk{eq("ctx\n"), add("n\n"), del("o1\no2\n")},
},
FileChange{Status: "M", Path: "a.txt", Additions: 1, Deletions: 2},
},
{
"renamed with edit",
fakeFilePatch{
from: file("old.txt"),
to: file("new.txt"),
chunks: []diff.Chunk{add("x\n"), del("y\n")},
},
FileChange{Status: "R", OldPath: "old.txt", Path: "new.txt", Additions: 1, Deletions: 1},
},
{
"binary skips counts",
fakeFilePatch{
from: file("img.bin"),
to: file("img.bin"),
binary: true,
chunks: []diff.Chunk{add("should be ignored\n")},
},
FileChange{Status: "M", Path: "img.bin", Binary: true},
},
{
"unterminated final line counts as one",
fakeFilePatch{to: file("f"), chunks: []diff.Chunk{add("noeol")}},
FileChange{Status: "A", Path: "f", Additions: 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mapFilePatches([]diff.FilePatch{tt.fp})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0] != tt.want {
t.Errorf("got %+v, want %+v", got[0], tt.want)
}
})
}
t.Run("empty from and to is skipped", func(t *testing.T) {
got := mapFilePatches([]diff.FilePatch{fakeFilePatch{}})
if len(got) != 0 {
t.Errorf("got %+v, want none", got)
}
})
}
func TestCutPatch(t *testing.T) {
fileA := "diff --git a/a.txt b/a.txt\n@@ -1 +1 @@\n-x\n+y\n"
fileB := "diff --git a/b.txt b/b.txt\n@@ -1 +1 @@\n-p\n+q\n"
full := fileA + fileB
t.Run("under limit unchanged", func(t *testing.T) {
got, trunc := cutPatch(full, 1<<20)
if trunc || got != full {
t.Errorf("trunc=%v got=%q", trunc, got)
}
})
t.Run("zero limit unchanged", func(t *testing.T) {
got, trunc := cutPatch(full, 0)
if trunc || got != full {
t.Errorf("trunc=%v", trunc)
}
})
t.Run("cut at file boundary drops torn file", func(t *testing.T) {
// A limit that lands inside fileB must cut back to the end of fileA.
limit := int64(len(fileA) + 10)
got, trunc := cutPatch(full, limit)
if !trunc {
t.Fatalf("expected truncated")
}
if got != fileA {
t.Errorf("got %q, want just fileA (no torn diff --git)", got)
}
if strings.Count(got, "diff --git") != 1 {
t.Errorf("torn file leaked: %q", got)
}
})
t.Run("single huge leading file cut hard at limit", func(t *testing.T) {
huge := "diff --git a/big b/big\n" + strings.Repeat("+line\n", 1000)
got, trunc := cutPatch(huge, 40)
if !trunc {
t.Fatalf("expected truncated")
}
if int64(len(got)) != 40 {
t.Errorf("len = %d, want 40", len(got))
}
})
}