From dd097cda6a274732612d691513d552d0edeb630c Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Mon, 23 Feb 2026 09:24:35 +0100 Subject: [PATCH 1/2] feat: add per-file added/deleted line counts in file list --- internal/git/repo.go | 82 ++++++++++++++++++++++++++++++++++++--- internal/git/repo_test.go | 20 ++++++++++ internal/ui/model.go | 59 +++++++++++++++++++--------- internal/ui/model_test.go | 12 +++++- 4 files changed, 149 insertions(+), 24 deletions(-) diff --git a/internal/git/repo.go b/internal/git/repo.go index 3a2f55a..5d6ab7a 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -24,10 +24,12 @@ const ( // FileChange represents a changed file in the working tree or index. type FileChange struct { - Path string - OldPath string // non-empty for renames - Status FileStatus - Staged bool + Path string + OldPath string // non-empty for renames + Status FileStatus + Staged bool + AddedLines int + DeletedLines int } // UpstreamInfo holds ahead/behind counts relative to the upstream branch. @@ -168,6 +170,11 @@ func (r *Repo) ChangedFiles(staged bool, ref string) ([]FileChange, error) { if err != nil { return nil, err } + stagedStats, err := r.diffNumStat("--cached") + if err != nil { + return nil, err + } + applyStats(stagedFiles, stagedStats) for i := range stagedFiles { stagedFiles[i].Staged = true } @@ -182,6 +189,11 @@ func (r *Repo) ChangedFiles(staged bool, ref string) ([]FileChange, error) { if err != nil { return nil, err } + unstagedStats, err := r.diffNumStat() + if err != nil { + return nil, err + } + applyStats(unstagedFiles, unstagedStats) files = append(files, unstagedFiles...) return files, nil @@ -335,13 +347,73 @@ func (r *Repo) diffNameStatus(extraArgs ...string) ([]FileChange, error) { return parseNameStatus(out), nil } +func (r *Repo) diffNumStat(extraArgs ...string) (map[string]lineStats, error) { + args := append([]string{"diff", "--numstat", "--no-ext-diff", "--color=never"}, extraArgs...) + out, err := r.run(args...) + if err != nil { + return nil, err + } + return parseNumStat(out), nil +} + // changedFilesRef returns files changed compared to a ref. func (r *Repo) changedFilesRef(ref string) ([]FileChange, error) { out, err := r.run("diff", "--name-status", ref) if err != nil { return nil, err } - return parseNameStatus(out), nil + files := parseNameStatus(out) + stats, err := r.diffNumStat(ref) + if err != nil { + return nil, err + } + applyStats(files, stats) + return files, nil +} + +type lineStats struct { + added int + deleted int +} + +func parseNumStat(out string) map[string]lineStats { + stats := make(map[string]lineStats) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 3 { + continue + } + path := parts[len(parts)-1] + added := parseNumStatInt(parts[0]) + deleted := parseNumStatInt(parts[1]) + stats[path] = lineStats{added: added, deleted: deleted} + } + return stats +} + +func parseNumStatInt(s string) int { + if s == "-" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} + +func applyStats(files []FileChange, stats map[string]lineStats) { + for i := range files { + st, ok := stats[files[i].Path] + if !ok { + continue + } + files[i].AddedLines = st.added + files[i].DeletedLines = st.deleted + } } // parseNameStatus parses git diff --name-status output. diff --git a/internal/git/repo_test.go b/internal/git/repo_test.go index b7c67df..4f702f0 100644 --- a/internal/git/repo_test.go +++ b/internal/git/repo_test.go @@ -150,6 +150,17 @@ func TestParseLog(t *testing.T) { } } +func TestParseNumStat(t *testing.T) { + t.Parallel() + got := parseNumStat("12\t3\tfile.go\n-\t-\tbinary.dat") + if got["file.go"].added != 12 || got["file.go"].deleted != 3 { + t.Fatalf("file.go stats mismatch: %+v", got["file.go"]) + } + if got["binary.dat"].added != 0 || got["binary.dat"].deleted != 0 { + t.Fatalf("binary stats mismatch: %+v", got["binary.dat"]) + } +} + // --- Integration tests --- func TestNewRepo_Valid(t *testing.T) { @@ -213,6 +224,9 @@ func TestChangedFiles_Unstaged(t *testing.T) { if files[0].Staged { t.Error("file should not be staged") } + if files[0].AddedLines == 0 { + t.Error("expected unstaged added lines > 0") + } } func TestChangedFiles_Staged(t *testing.T) { @@ -230,6 +244,9 @@ func TestChangedFiles_Staged(t *testing.T) { for _, f := range files { if f.Staged && f.Path == "f.txt" { found = true + if f.AddedLines == 0 { + t.Error("expected staged added lines > 0") + } } } if !found { @@ -270,6 +287,9 @@ func TestChangedFiles_Ref(t *testing.T) { if len(files) != 1 { t.Fatalf("expected 1 file, got %d", len(files)) } + if files[0].AddedLines == 0 { + t.Error("expected ref diff added lines > 0") + } } func TestUntrackedFiles(t *testing.T) { diff --git a/internal/ui/model.go b/internal/ui/model.go index b7817ef..c984a27 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -81,18 +81,18 @@ type Model struct { stagedOnly bool ref string - mode viewMode - cursor int - prevCurs int - viewport viewport.Model - commitInput textinput.Model - statusMsg string + mode viewMode + cursor int + prevCurs int + viewport viewport.Model + commitInput textinput.Model + statusMsg string generatingMsg bool - splitDiff bool - width int - height int - ready bool - SelectedFile string // set on "open in editor" action, read after Run() + splitDiff bool + width int + height int + ready bool + SelectedFile string // set on "open in editor" action, read after Run() // Branch picker state branches []string @@ -121,7 +121,7 @@ func NewModel( stagedOnly bool, ref string, ) Model { - files := buildFileItems(changes, untracked) + files := buildFileItems(repo, changes, untracked) ti := textinput.New() ti.Placeholder = "commit message..." @@ -141,20 +141,38 @@ func NewModel( } } -func buildFileItems(changes []git.FileChange, untracked []string) []fileItem { +func buildFileItems(repo *git.Repo, changes []git.FileChange, untracked []string) []fileItem { var files []fileItem for _, c := range changes { files = append(files, fileItem{change: c}) } for _, path := range untracked { + added := 0 + if repo != nil { + raw, err := repo.ReadFileContent(path) + if err == nil { + added = countLines(raw) + } + } files = append(files, fileItem{ - change: git.FileChange{Path: path, Status: git.StatusUntracked}, + change: git.FileChange{Path: path, Status: git.StatusUntracked, AddedLines: added, DeletedLines: 0}, untracked: true, }) } return files } +func countLines(s string) int { + if s == "" { + return 0 + } + count := strings.Count(s, "\n") + if !strings.HasSuffix(s, "\n") { + count++ + } + return count +} + func filesEqual(a, b []fileItem) bool { if len(a) != len(b) { return false @@ -674,7 +692,7 @@ func (m Model) refreshFilesCmd() tea.Cmd { if !stagedOnly && ref == "" { untracked, _ = repo.UntrackedFiles() } - return filesRefreshedMsg{files: buildFileItems(files, untracked)} + return filesRefreshedMsg{files: buildFileItems(repo, files, untracked)} } } @@ -684,7 +702,7 @@ func (m Model) buildRefreshedFiles() filesRefreshedMsg { if !m.stagedOnly && m.ref == "" { untracked, _ = m.repo.UntrackedFiles() } - return filesRefreshedMsg{files: buildFileItems(files, untracked)} + return filesRefreshedMsg{files: buildFileItems(m.repo, files, untracked)} } type savePrefDoneMsg struct{ err error } @@ -858,13 +876,18 @@ func (m Model) renderFileItem(f fileItem, selected bool) string { } statusStyled := m.styleStatus(status, f.change.Status) + stats := fmt.Sprintf("+%d -%d", f.change.AddedLines, f.change.DeletedLines) name := filepath.Base(f.change.Path) if f.change.OldPath != "" { name = filepath.Base(f.change.OldPath) + " → " + filepath.Base(f.change.Path) } - name = truncatePath(name, fileListWidth-10) + nameMaxW := fileListWidth - lipgloss.Width(staged) - lipgloss.Width(status) - 1 - lipgloss.Width(stats) - 1 + if nameMaxW < 1 { + nameMaxW = 1 + } + name = truncatePath(name, nameMaxW) - line := fmt.Sprintf("%s%s %s", staged, statusStyled, name) + line := fmt.Sprintf("%s%s %s %s", staged, statusStyled, name, stats) if selected { return m.styles.FileSelected.Width(fileListWidth).Render(line) } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 07eb1df..297a497 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -27,7 +27,7 @@ func TestBuildFileItems(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := buildFileItems(tt.changes, tt.untracked) + got := buildFileItems(nil, tt.changes, tt.untracked) if len(got) != tt.wantLen { t.Errorf("len=%d, want %d", len(got), tt.wantLen) } @@ -259,6 +259,16 @@ func TestRenderBranchItem_Current(t *testing.T) { } } +func TestRenderFileItem_ShowsStats(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + item := fileItem{change: git.FileChange{Path: "main.go", Status: git.StatusModified, AddedLines: 12, DeletedLines: 3}} + out := m.renderFileItem(item, false) + if !strings.Contains(out, "+12 -3") { + t.Errorf("expected stats in file item, got %q", out) + } +} + func TestUpdateBranchMode_Navigation(t *testing.T) { t.Parallel() m := newTestModel(t, nil) From 2b40e8738e768a6e66a1d89b832ac8c78cb528fd Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Mon, 23 Feb 2026 09:28:28 +0100 Subject: [PATCH 2/2] fix: handle rename numstat stats mapping --- internal/git/repo.go | 28 +++++++++++++++++++++++++--- internal/git/repo_test.go | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/git/repo.go b/internal/git/repo.go index 5d6ab7a..f9a6553 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -339,7 +339,7 @@ func (r *Repo) diffNameStatusEmptyTree() ([]FileChange, error) { // diffNameStatus runs git diff --name-status with optional extra args. func (r *Repo) diffNameStatus(extraArgs ...string) ([]FileChange, error) { - args := append([]string{"diff", "--name-status"}, extraArgs...) + args := append([]string{"diff", "--name-status", "--no-ext-diff", "--color=never"}, extraArgs...) out, err := r.run(args...) if err != nil { return nil, err @@ -358,7 +358,7 @@ func (r *Repo) diffNumStat(extraArgs ...string) (map[string]lineStats, error) { // changedFilesRef returns files changed compared to a ref. func (r *Repo) changedFilesRef(ref string) ([]FileChange, error) { - out, err := r.run("diff", "--name-status", ref) + out, err := r.run("diff", "--name-status", "--no-ext-diff", "--color=never", ref) if err != nil { return nil, err } @@ -386,7 +386,7 @@ func parseNumStat(out string) map[string]lineStats { if len(parts) < 3 { continue } - path := parts[len(parts)-1] + path := parseNumStatPath(parts[len(parts)-1]) added := parseNumStatInt(parts[0]) deleted := parseNumStatInt(parts[1]) stats[path] = lineStats{added: added, deleted: deleted} @@ -394,6 +394,28 @@ func parseNumStat(out string) map[string]lineStats { return stats } +func parseNumStatPath(path string) string { + if !strings.Contains(path, " => ") { + return path + } + if strings.Contains(path, "{") && strings.Contains(path, "}") { + open := strings.Index(path, "{") + close := strings.LastIndex(path, "}") + if open >= 0 && close > open { + inside := path[open+1 : close] + parts := strings.SplitN(inside, " => ", 2) + if len(parts) == 2 { + return path[:open] + parts[1] + path[close+1:] + } + } + } + parts := strings.SplitN(path, " => ", 2) + if len(parts) == 2 { + return parts[1] + } + return path +} + func parseNumStatInt(s string) int { if s == "-" { return 0 diff --git a/internal/git/repo_test.go b/internal/git/repo_test.go index 4f702f0..7a9f1f1 100644 --- a/internal/git/repo_test.go +++ b/internal/git/repo_test.go @@ -152,13 +152,19 @@ func TestParseLog(t *testing.T) { func TestParseNumStat(t *testing.T) { t.Parallel() - got := parseNumStat("12\t3\tfile.go\n-\t-\tbinary.dat") + got := parseNumStat("12\t3\tfile.go\n-\t-\tbinary.dat\n5\t2\told/name.go => new/name.go\n7\t1\tsrc/{old => new}/name.go") if got["file.go"].added != 12 || got["file.go"].deleted != 3 { t.Fatalf("file.go stats mismatch: %+v", got["file.go"]) } if got["binary.dat"].added != 0 || got["binary.dat"].deleted != 0 { t.Fatalf("binary stats mismatch: %+v", got["binary.dat"]) } + if got["new/name.go"].added != 5 || got["new/name.go"].deleted != 2 { + t.Fatalf("rename stats mismatch: %+v", got["new/name.go"]) + } + if got["src/new/name.go"].added != 7 || got["src/new/name.go"].deleted != 1 { + t.Fatalf("brace rename stats mismatch: %+v", got["src/new/name.go"]) + } } // --- Integration tests --- @@ -292,6 +298,32 @@ func TestChangedFiles_Ref(t *testing.T) { } } +func TestChangedFiles_StagedRenameWithEdits_HasStats(t *testing.T) { + t.Parallel() + repo := setupTestRepo(t) + addCommit(t, repo, "old.txt", "one\n", "init") + gitRun(t, repo.Dir(), "mv", "old.txt", "new.txt") + writeFile(t, repo, "new.txt", "one\ntwo\n") + gitRun(t, repo.Dir(), "add", "-A") + + files, err := repo.ChangedFiles(true, "") + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Fatalf("expected 1 staged file, got %d", len(files)) + } + if files[0].Status != StatusRenamed { + t.Fatalf("expected renamed status, got %c", files[0].Status) + } + if files[0].Path != "new.txt" { + t.Fatalf("expected new path, got %q", files[0].Path) + } + if files[0].AddedLines == 0 { + t.Fatal("expected non-zero added lines for staged rename with edits") + } +} + func TestUntrackedFiles(t *testing.T) { t.Parallel() repo := setupTestRepo(t)