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") + } +}