diff --git a/internal/app/app.go b/internal/app/app.go index 45f98d5..bade76b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -145,7 +145,7 @@ func commitDiff(amend bool) (string, error) { if amend { return git.LastCommitDiff() } - return git.StagedDiff() + return git.StagedDiffWithSummary() } func stageChanges(addAll bool, includeFiles []string) (func(), error) { diff --git a/internal/git/lockfile.go b/internal/git/lockfile.go new file mode 100644 index 0000000..5090e60 --- /dev/null +++ b/internal/git/lockfile.go @@ -0,0 +1,157 @@ +package git + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// lockFileNames contains lock files that don't match common suffixes +var lockFileNames = map[string]bool{ + "go.sum": true, +} + +var lockFileSuffixes = []string{ + ".lock", + "-lock.json", + "-lock.yaml", +} + +// LockFileSummaryThreshold is the minimum diff size in bytes +// before a lock file diff is summarized instead of shown in full. +// 150KB is chosen based on LLM context limits (Claude Haiku: 200k tokens). +const LockFileSummaryThreshold = 150 * 1024 // 150KB + +func IsLockFile(filename string) bool { + base := filepath.Base(filename) + + if lockFileNames[base] { + return true + } + + lower := strings.ToLower(base) + for _, suffix := range lockFileSuffixes { + if strings.HasSuffix(lower, suffix) { + return true + } + } + + return false +} + +type FileStat struct { + Filename string + Added int + Deleted int + Binary bool +} + +func ParseNumstat(output string) []FileStat { + var stats []FileStat + lines := strings.Split(output, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Split(line, "\t") + if len(parts) != 3 { + continue + } + + stat := FileStat{Filename: parts[2]} + + if parts[0] == "-" && parts[1] == "-" { + stat.Binary = true + } else { + added, _ := strconv.Atoi(parts[0]) + deleted, _ := strconv.Atoi(parts[1]) + stat.Added = added + stat.Deleted = deleted + } + + stats = append(stats, stat) + } + + return stats +} + +func StagedDiffWithSummary() (string, error) { + numstatCmd := exec.Command("git", "diff", "--staged", "--numstat") + var numstatOut bytes.Buffer + var numstatErr bytes.Buffer + numstatCmd.Stdout = &numstatOut + numstatCmd.Stderr = &numstatErr + if err := numstatCmd.Run(); err != nil { + return "", fmt.Errorf("git diff --numstat failed: %v: %s", err, strings.TrimSpace(numstatErr.String())) + } + + stats := ParseNumstat(numstatOut.String()) + + // Get diff for each file and check if lock files exceed threshold + type fileDiffInfo struct { + stat FileStat + diff string + isLarge bool + } + fileDiffs := make([]fileDiffInfo, 0, len(stats)) + + for _, stat := range stats { + diff, err := stagedDiffForFile(stat.Filename) + if err != nil { + return "", err + } + + isLarge := IsLockFile(stat.Filename) && len(diff) >= LockFileSummaryThreshold + fileDiffs = append(fileDiffs, fileDiffInfo{ + stat: stat, + diff: diff, + isLarge: isLarge, + }) + } + + // Check if any lock file exceeds threshold + hasLargeLockFile := false + for _, fd := range fileDiffs { + if fd.isLarge { + hasLargeLockFile = true + break + } + } + + // If no large lock files, return standard diff + if !hasLargeLockFile { + return StagedDiff() + } + + var result strings.Builder + + for _, fd := range fileDiffs { + if fd.isLarge { + result.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", fd.stat.Filename, fd.stat.Filename)) + result.WriteString(fmt.Sprintf("[Lock file: +%d -%d lines, %d bytes, content omitted]\n\n", + fd.stat.Added, fd.stat.Deleted, len(fd.diff))) + } else { + result.WriteString(fd.diff) + } + } + + return result.String(), nil +} + +func stagedDiffForFile(filename string) (string, error) { + cmd := exec.Command("git", "diff", "--staged", "--", filename) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git diff for %s failed: %v: %s", filename, err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} diff --git a/internal/git/lockfile_test.go b/internal/git/lockfile_test.go new file mode 100644 index 0000000..b970fd1 --- /dev/null +++ b/internal/git/lockfile_test.go @@ -0,0 +1,261 @@ +package git + +import ( + "strconv" + "strings" + "testing" +) + +func TestIsLockFile(t *testing.T) { + tests := []struct { + name string + filename string + want bool + }{ + // Lock files that should be detected + {"uv.lock", "uv.lock", true}, + {"poetry.lock", "poetry.lock", true}, + {"package-lock.json", "package-lock.json", true}, + {"yarn.lock", "yarn.lock", true}, + {"pnpm-lock.yaml", "pnpm-lock.yaml", true}, + {"Gemfile.lock", "Gemfile.lock", true}, + {"Cargo.lock", "Cargo.lock", true}, + {"go.sum", "go.sum", true}, + {"composer.lock", "composer.lock", true}, + + // Lock files in subdirectories + {"nested uv.lock", "packages/uv.lock", true}, + {"nested package-lock.json", "frontend/package-lock.json", true}, + + // Non-lock files + {"regular go file", "main.go", false}, + {"regular json", "config.json", false}, + {"go.mod", "go.mod", false}, + {"package.json", "package.json", false}, + {"Gemfile", "Gemfile", false}, + {"Cargo.toml", "Cargo.toml", false}, + {"pyproject.toml", "pyproject.toml", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsLockFile(tt.filename) + if got != tt.want { + t.Errorf("IsLockFile(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} + +func TestParseNumstat(t *testing.T) { + // git diff --numstat output format: addeddeletedfilename + input := strings.Join([]string{ + "10\t5\tmain.go", + "500\t200\tuv.lock", + "3\t1\tREADME.md", + }, "\n") + + stats := ParseNumstat(input) + + if len(stats) != 3 { + t.Fatalf("expected 3 stats, got %d", len(stats)) + } + + tests := []struct { + filename string + added int + deleted int + }{ + {"main.go", 10, 5}, + {"uv.lock", 500, 200}, + {"README.md", 3, 1}, + } + + for i, tt := range tests { + if stats[i].Filename != tt.filename { + t.Errorf("stats[%d].Filename = %q, want %q", i, stats[i].Filename, tt.filename) + } + if stats[i].Added != tt.added { + t.Errorf("stats[%d].Added = %d, want %d", i, stats[i].Added, tt.added) + } + if stats[i].Deleted != tt.deleted { + t.Errorf("stats[%d].Deleted = %d, want %d", i, stats[i].Deleted, tt.deleted) + } + } +} + +func TestParseNumstatBinaryFile(t *testing.T) { + // Binary files show as "-" in numstat + input := "-\t-\timage.png" + + stats := ParseNumstat(input) + + if len(stats) != 1 { + t.Fatalf("expected 1 stat, got %d", len(stats)) + } + + if stats[0].Filename != "image.png" { + t.Errorf("Filename = %q, want %q", stats[0].Filename, "image.png") + } + if !stats[0].Binary { + t.Error("expected Binary to be true") + } +} + +func TestStagedDiffWithSummary(t *testing.T) { + repo := setupRepo(t) + withRepo(t, repo, func() { + // Create a regular file and a large lock file (above 150KB threshold) + writeFile(t, repo, "main.go", "package main\n\nfunc main() {}\n") + writeLockFileWithSize(t, repo, "uv.lock", 160*1024) // 160KB, above threshold + + runGit(t, repo, "add", "main.go", "uv.lock") + + diff, err := StagedDiffWithSummary() + if err != nil { + t.Fatalf("StagedDiffWithSummary error: %v", err) + } + + // Should contain full diff for main.go + if !strings.Contains(diff, "package main") { + t.Error("expected diff to contain main.go content") + } + + // Should contain summary for uv.lock, not full content + if !strings.Contains(diff, "uv.lock") { + t.Error("expected diff to mention uv.lock") + } + if strings.Contains(diff, "package-version-1") { + t.Error("expected diff NOT to contain uv.lock content details") + } + // Should contain "content omitted" marker + if !strings.Contains(diff, "content omitted") { + t.Error("expected diff to contain summary marker") + } + }) +} + +// writeLockFileWithSize creates a lock file with approximately the specified size in bytes +func writeLockFileWithSize(t *testing.T, repo, name string, targetBytes int) { + t.Helper() + var content strings.Builder + lineSize := len("package-version-0 = \"1.0.0\"\n") + lines := targetBytes / lineSize + for i := 0; i < lines; i++ { + content.WriteString("package-version-") + content.WriteString(strconv.Itoa(i)) + content.WriteString(" = \"1.0.0\"\n") + } + writeFile(t, repo, name, content.String()) +} + +// writeLargeLockFile creates a lock file with the specified number of lines (for backward compatibility) +func writeLargeLockFile(t *testing.T, repo, name string, lines int) { + t.Helper() + var content strings.Builder + for i := 0; i < lines; i++ { + content.WriteString("package-version-") + content.WriteString(strconv.Itoa(i)) + content.WriteString(" = \"1.0.0\"\n") + } + writeFile(t, repo, name, content.String()) +} + +func TestStagedDiffWithSummaryOnlyLargeLockFile(t *testing.T) { + repo := setupRepo(t) + withRepo(t, repo, func() { + // Only stage a large lock file (above 150KB threshold) + writeLockFileWithSize(t, repo, "package-lock.json", 160*1024) + runGit(t, repo, "add", "package-lock.json") + + diff, err := StagedDiffWithSummary() + if err != nil { + t.Fatalf("StagedDiffWithSummary error: %v", err) + } + + // Should contain summary + if !strings.Contains(diff, "package-lock.json") { + t.Error("expected diff to mention package-lock.json") + } + if !strings.Contains(diff, "Lock file") { + t.Error("expected diff to contain lock file summary marker") + } + // Should NOT contain actual content + if strings.Contains(diff, "package-version-") { + t.Error("expected diff NOT to contain lock file content") + } + }) +} + +func TestStagedDiffWithSummaryNoLockFiles(t *testing.T) { + repo := setupRepo(t) + withRepo(t, repo, func() { + // Only regular files + writeFile(t, repo, "main.go", "package main\n") + writeFile(t, repo, "util.go", "package util\n") + runGit(t, repo, "add", "main.go", "util.go") + + diff, err := StagedDiffWithSummary() + if err != nil { + t.Fatalf("StagedDiffWithSummary error: %v", err) + } + + // Should contain full diff + if !strings.Contains(diff, "package main") { + t.Error("expected diff to contain main.go content") + } + if !strings.Contains(diff, "package util") { + t.Error("expected diff to contain util.go content") + } + // Should NOT contain lock file marker + if strings.Contains(diff, "Lock file") { + t.Error("expected diff NOT to contain lock file marker") + } + }) +} + +func TestStagedDiffWithSummarySmallLockFile(t *testing.T) { + repo := setupRepo(t) + withRepo(t, repo, func() { + // Small lock file (below 150KB threshold) should show full diff + writeLockFileWithSize(t, repo, "uv.lock", 100*1024) // 100KB, below threshold + runGit(t, repo, "add", "uv.lock") + + diff, err := StagedDiffWithSummary() + if err != nil { + t.Fatalf("StagedDiffWithSummary error: %v", err) + } + + // Should contain full diff content (not summarized) + if !strings.Contains(diff, "package-version-") { + t.Error("expected small lock file to show full diff content") + } + // Should NOT contain summary marker + if strings.Contains(diff, "content omitted") { + t.Error("expected small lock file NOT to be summarized") + } + }) +} + +func TestStagedDiffWithSummaryLargeLockFile(t *testing.T) { + repo := setupRepo(t) + withRepo(t, repo, func() { + // Large lock file (above 150KB threshold) should be summarized + writeLockFileWithSize(t, repo, "uv.lock", 160*1024) // 160KB, above threshold + runGit(t, repo, "add", "uv.lock") + + diff, err := StagedDiffWithSummary() + if err != nil { + t.Fatalf("StagedDiffWithSummary error: %v", err) + } + + // Should contain summary marker + if !strings.Contains(diff, "content omitted") { + t.Error("expected large lock file to be summarized") + } + // Should NOT contain full content + if strings.Contains(diff, "package-version-") { + t.Error("expected large lock file NOT to show full content") + } + }) +}