Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 101 additions & 7 deletions internal/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 23, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +177
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
for i := range stagedFiles {
stagedFiles[i].Staged = true
}
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions internal/git/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

// --- Integration tests ---

func TestNewRepo_Valid(t *testing.T) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
59 changes: 41 additions & 18 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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..."
Expand All @@ -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
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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
}

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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))
}
}
}

Copilot uses AI. Check for mistakes.
func filesEqual(a, b []fileItem) bool {
if len(a) != len(b) {
return false
Expand Down Expand Up @@ -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)}
}
}

Expand All @@ -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 }
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 11 additions & 1 deletion internal/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down