fix(agentctl): populate diff for renamed-and-modified files in git status (#730)

* fix(agentctl): populate diff for renamed-and-modified files in git status

The numstat output uses notation like `old.txt => new.txt` for renamed
files, but the Files map is keyed only by the new path. Resolve numstat
paths to the new path before map lookup so RM file diffs are populated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(agentctl): cover filename-starts-with-brace rename case

Adds a regression test for `{{page => layout}}.svelte` to lock in that
LastIndex on `{` correctly picks git's brace block over the filename's
leading literal `{`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-04-29 00:10:17 +01:00
committed by GitHub
parent 51cc14d028
commit c7274e84c1
2 changed files with 99 additions and 2 deletions
@@ -129,6 +129,29 @@ func capDiffOutput(ctx context.Context, workDir string, args ...string) (string,
return string(data), truncated
}
// resolveNumstatPath resolves a numstat path that may contain rename notation
// (e.g. "old.txt => new.txt" or "{old => new}/file.txt") to the new file path.
// For non-rename paths, returns the input unchanged.
func resolveNumstatPath(numstatPath string) string {
arrowIdx := strings.Index(numstatPath, " => ")
if arrowIdx == -1 {
return numstatPath
}
// Check for brace-style rename: {old => new}/suffix or prefix/{old => new}/suffix
braceOpen := strings.LastIndex(numstatPath[:arrowIdx], "{")
braceClose := strings.Index(numstatPath[arrowIdx:], "}")
if braceOpen != -1 && braceClose != -1 {
prefix := numstatPath[:braceOpen]
newPart := numstatPath[arrowIdx+4 : arrowIdx+braceClose]
suffix := numstatPath[arrowIdx+braceClose+1:]
return prefix + newPart + suffix
}
// Simple rename: "old.txt => new.txt"
return numstatPath[arrowIdx+4:]
}
// enrichWithUnstagedDiff populates additions/deletions and diff content for files
// with unstaged changes by comparing the worktree against baseRef.
func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *types.GitStatusUpdate, baseRef string) {
@@ -154,7 +177,11 @@ func (wt *WorkspaceTracker) enrichWithUnstagedDiff(ctx context.Context, update *
}
additions, _ := strconv.Atoi(parts[0])
deletions, _ := strconv.Atoi(parts[1])
filePath := parts[2]
numstatPath := parts[2]
// Resolve rename notation (e.g. "old => new") to the new path,
// which is the key used in the Files map.
filePath := resolveNumstatPath(numstatPath)
// Only update file info if it exists in status (uncommitted changes).
// Files that appear in diff but not in status are committed changes - we don't
@@ -210,7 +237,11 @@ func (wt *WorkspaceTracker) enrichWithStagedDiff(ctx context.Context, update *ty
}
additions, _ := strconv.Atoi(parts[0])
deletions, _ := strconv.Atoi(parts[1])
filePath := parts[2]
numstatPath := parts[2]
// Resolve rename notation (e.g. "old => new") to the new path,
// which is the key used in the Files map.
filePath := resolveNumstatPath(numstatPath)
fileInfo, exists := update.Files[filePath]
if !exists {
@@ -217,3 +217,69 @@ func TestEnrichUntrackedFileDiffs_SmallTextFile(t *testing.T) {
t.Errorf("Additions = %d, want 1", fi.Additions)
}
}
func TestResolveNumstatPath(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"plain path", "src/main.go", "src/main.go"},
{"simple rename", "old.txt => new.txt", "new.txt"},
{"brace rename same dir", "{old.txt => new.txt}", "new.txt"},
{"brace rename with prefix", "src/{old.go => new.go}", "src/new.go"},
{"brace rename with suffix", "{old => new}/file.go", "new/file.go"},
{"brace rename with prefix and suffix", "src/{v1 => v2}/handler.go", "src/v2/handler.go"},
{"directory rename", "pkg/{old => new}/main.go", "pkg/new/main.go"},
{"path with spaces", "my dir/file.txt", "my dir/file.txt"},
{"rename with spaces", "old file.txt => new file.txt", "new file.txt"},
// LastIndex on `{` ensures git's own brace is picked, not the filename's leading `{`.
{"filename starts with brace", "{{page => layout}}.svelte", "{layout}.svelte"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveNumstatPath(tt.input)
if got != tt.expected {
t.Errorf("resolveNumstatPath(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestEnrichStagedDiff_RenamedFile(t *testing.T) {
isolateTestGitEnv(t)
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
// Create and commit a file, then rename + modify it
writeFile(t, repoDir, "original.txt", "line1\nline2\nline3\n")
runGit(t, repoDir, "add", "original.txt")
runGit(t, repoDir, "commit", "-m", "add original")
runGit(t, repoDir, "mv", "original.txt", "renamed.txt")
writeFile(t, repoDir, "renamed.txt", "line1\nline2 modified\nline3\nline4\n")
runGit(t, repoDir, "add", "renamed.txt")
log := newTestLogger(t)
wt := NewWorkspaceTracker(repoDir, log)
update := &streams.GitStatusUpdate{
Files: map[string]streams.FileInfo{
"renamed.txt": {
Path: "renamed.txt",
Status: "renamed",
Staged: true,
OldPath: "original.txt",
},
},
}
wt.enrichWithStagedDiff(context.Background(), update, "HEAD")
fi := update.Files["renamed.txt"]
if fi.Diff == "" {
t.Error("expected non-empty Diff for renamed+modified file")
}
if fi.Additions == 0 && fi.Deletions == 0 {
t.Error("expected non-zero additions or deletions for renamed+modified file")
}
}