Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds per-file diff statistics (added/deleted line counts) to the file list display. The implementation fetches stats using git diff --numstat for staged, unstaged, and ref comparison modes, and computes stats for untracked files by counting lines from file content.
Changes:
- Added
AddedLinesandDeletedLinesfields togit.FileChangestruct to store line statistics - Implemented
parseNumStat,parseNumStatPath,parseNumStatInt, andapplyStatsfunctions to parse and apply git numstat output - Modified
buildFileItemsto compute line counts for untracked files using a newcountLinesfunction - Updated
renderFileItemto display stats in the format+N -Nwith width-aware truncation - Added test coverage for git parsing logic and UI rendering
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| internal/git/repo.go | Added numstat parsing functions and integrated stats fetching into ChangedFiles workflow |
| internal/git/repo_test.go | Added unit tests for parseNumStat and integration tests verifying stats are populated |
| internal/ui/model.go | Added countLines function for untracked files and updated renderFileItem to display stats |
| internal/ui/model_test.go | Added test verifying stats are displayed in rendered file items |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| stagedStats, err := r.diffNumStat("--cached") | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| applyStats(stagedFiles, stagedStats) |
There was a problem hiding this comment.
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.
| stagedStats, err := r.diffNumStat("--cached") | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| applyStats(stagedFiles, stagedStats) |
There was a problem hiding this comment.
When the repository has no commits yet (line 167-168), diffNameStatusEmptyTree uses diff-index against the empty tree hash, but diffNumStat uses git diff --numstat --cached. While git diff --numstat --cached should work correctly against an empty tree internally, this inconsistency could be confusing. Consider adding a comment explaining that git diff handles the empty tree case automatically, or add a test case that specifically validates stats are correctly populated for staged files in a repository with no commits.
| } | ||
| return count | ||
| } | ||
|
|
There was a problem hiding this comment.
The countLines function is missing test coverage. Consider adding unit tests to verify edge cases such as: empty strings, strings without trailing newlines, strings with only newlines, and strings with multiple lines both with and without trailing newlines. This would ensure the line counting logic behaves correctly for all untracked file scenarios.
| 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)) | |
| } | |
| } | |
| } |
| 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"]) | ||
| } | ||
| } |
There was a problem hiding this comment.
Consider adding test cases for parseNumStat that cover edge cases such as: empty input strings, files with zero additions and deletions (0 0), lines with fewer than 3 tab-separated parts, and malformed rename syntax. While the function handles these cases gracefully by skipping invalid lines, explicit tests would document the expected behavior.
| for _, path := range untracked { | ||
| added := 0 | ||
| if repo != nil { | ||
| raw, err := repo.ReadFileContent(path) | ||
| if err == nil { | ||
| added = countLines(raw) | ||
| } | ||
| } |
There was a problem hiding this comment.
Reading the full content of all untracked files synchronously could cause performance issues in repositories with many or very large untracked files. Consider implementing one of these optimizations: (1) read only the first N lines needed for counting, (2) perform the file reading asynchronously, or (3) add a size check to skip files above a certain threshold. While this is acceptable for typical use cases, it could impact user experience in edge cases.
Summary
+N -Nstats from git numstat for staged, unstaged, and ref compare modes+N -0from file content line count