-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add diff stats in file list #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Comment on lines
+173
to
+177
|
||
| 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 | ||
|
|
@@ -327,21 +339,103 @@ 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 | ||
| } | ||
| 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) | ||
| out, err := r.run("diff", "--name-status", "--no-ext-diff", "--color=never", 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 := parseNumStatPath(parts[len(parts)-1]) | ||
| added := parseNumStatInt(parts[0]) | ||
| deleted := parseNumStatInt(parts[1]) | ||
| stats[path] = lineStats{added: added, deleted: deleted} | ||
| } | ||
| 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 | ||
| } | ||
| 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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -150,6 +150,23 @@ func TestParseLog(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| func TestParseNumStat(t *testing.T) { | ||
| t.Parallel() | ||
| 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"]) | ||
| } | ||
| } | ||
|
Comment on lines
+153
to
+168
|
||
|
|
||
| // --- Integration tests --- | ||
|
|
||
| func TestNewRepo_Valid(t *testing.T) { | ||
|
|
@@ -213,6 +230,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 +250,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 +293,35 @@ 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 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
149
to
+156
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 init() { | |
| verifyCountLines() | |
| } | |
| func verifyCountLines() { | |
| type tc struct { | |
| input string | |
| expected int | |
| } | |
| tests := []tc{ | |
| // empty string | |
| {input: "", expected: 0}, | |
| // no trailing newline | |
| {input: "abc", expected: 1}, | |
| // with trailing newline | |
| {input: "abc\n", expected: 1}, | |
| // only newlines | |
| {input: "\n", expected: 1}, | |
| {input: "\n\n", expected: 2}, | |
| // multiple lines without trailing newline | |
| {input: "line1\nline2", expected: 2}, | |
| // multiple lines with trailing newline | |
| {input: "line1\nline2\n", expected: 2}, | |
| } | |
| for _, tt := range tests { | |
| if got := countLines(tt.input); got != tt.expected { | |
| panic(fmt.Sprintf("countLines(%q) = %d; want %d", tt.input, got, tt.expected)) | |
| } | |
| } | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider adding a test case that verifies stats are correctly populated for staged files in a repository with no commits (using diffNameStatusEmptyTree). While the code should work correctly since git diff --numstat --cached handles this internally, explicit test coverage would ensure this edge case is properly handled and document the expected behavior.