From c7274e84c1e211654b535b54284225a29baf451b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 29 Apr 2026 00:10:17 +0100 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- .../server/process/workspace_git_diff.go | 35 +++++++++- .../server/process/workspace_git_diff_test.go | 66 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go index 3329f0cedf..19c39ee13a 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go @@ -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 { diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go index 17ebf4085f..d7858a79e7 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go @@ -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") + } +}