diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 2e6524dcfc..4d7004494c 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -84,7 +84,15 @@ type WorkspaceTracker struct { // NewWorkspaceTracker creates a new workspace tracker func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { - resolvedWorkDir := resolveExistingWorkDir(workDir, log.WithFields(zap.String("component", "workspace-tracker"))) + tlog := log.WithFields(zap.String("component", "workspace-tracker")) + resolvedWorkDir := resolveExistingWorkDir(workDir, tlog) + // Multi-repo task roots are plain directories that hold one git worktree + // per repository as siblings. The git poller can't monitor a non-git path, + // so when the configured workDir isn't a git repo, fall back to the first + // child subdirectory that is. The user sees that repo's changes in the + // uncommitted-changes panel; the per-repo aggregation across all sibling + // worktrees is a follow-up. + resolvedWorkDir = preferGitRepoChildIfRootIsBare(resolvedWorkDir, tlog) // Cache validated git index path (works with worktrees where .git is a file) gitIndexPath := resolveGitIndexPath(resolvedWorkDir) @@ -117,6 +125,38 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { } } +// preferGitRepoChildIfRootIsBare returns workDir unchanged when it's already a +// git repo, otherwise returns the path of the first immediate child directory +// that is a git repo (or worktree). Used to make the workspace tracker work +// for multi-repo task roots, which are plain holder directories with each +// repo's worktree as a child. +// +// Returns workDir unchanged when no git child is found — the tracker will +// then run with no git data, matching the pre-multi-repo behavior for +// repo-less tasks (e.g. quick chat). +func preferGitRepoChildIfRootIsBare(workDir string, log *logger.Logger) string { + if workDir == "" || resolveGitIndexPath(workDir) != "" { + return workDir + } + entries, err := os.ReadDir(workDir) + if err != nil { + return workDir + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + candidate := filepath.Join(workDir, entry.Name()) + if resolveGitIndexPath(candidate) != "" { + log.Info("workspace tracker falling back to repo subdirectory (multi-repo task root is not itself a git repo)", + zap.String("task_root", workDir), + zap.String("repo_subdir", candidate)) + return candidate + } + } + return workDir +} + // resolveGitIndexPath returns the validated path to the git index file. // Returns empty string if the path cannot be resolved or validated. // This handles worktrees where .git is a file pointing elsewhere. diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go new file mode 100644 index 0000000000..4ed9b9df49 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go @@ -0,0 +1,73 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// TestWorkspaceTracker_FallsBackToRepoSubdir verifies the fix for the +// "changes panel empty on multi-repo task" issue. When the agent's workspace +// path is the multi-repo task root (a plain holder directory), the workspace +// tracker should fall back to the first git-repo child so it can still emit +// git status updates for at least one repo. +func TestWorkspaceTracker_FallsBackToRepoSubdir(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Build a synthetic task root that holds the test repo as a real + // sub-directory, matching what env_preparer creates for multi-repo tasks + // (worktrees placed under ~/.kandev/tasks/{TaskDirName}/{RepoName}/). + // We rename rather than symlink because the production layout uses real + // directories — and os.ReadDir's IsDir() returns false for symlinks. + taskRoot := t.TempDir() + repoSubdir := filepath.Join(taskRoot, "frontend") + if err := os.Rename(repoDir, repoSubdir); err != nil { + t.Fatalf("rename: %v", err) + } + + wt := NewWorkspaceTracker(taskRoot, newTestLogger(t)) + + // The tracker must have resolved its workDir to the repo subdir, otherwise + // getGitStatus would fail with "not a git repository". + if wt.workDir == taskRoot { + t.Fatalf("expected workDir to fall back to %q, got the bare task root %q", repoSubdir, taskRoot) + } + + status, err := wt.getGitStatus(context.Background()) + if err != nil { + t.Fatalf("getGitStatus on resolved subdir: %v", err) + } + if status.Branch == "" { + t.Errorf("expected non-empty branch from the fallback subdir; got status=%+v", status) + } +} + +// TestWorkspaceTracker_NoFallbackForRealRepo verifies the fallback only kicks +// in when the work dir itself is non-git. Single-repo tasks must keep their +// previous behavior verbatim. +func TestWorkspaceTracker_NoFallbackForRealRepo(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + wt := NewWorkspaceTracker(repoDir, newTestLogger(t)) + if wt.workDir != repoDir { + t.Errorf("real git repo workDir should not be rewritten; got %q want %q", wt.workDir, repoDir) + } +} + +// TestWorkspaceTracker_NoFallbackWhenNoGitChild verifies that a truly empty +// task root (no git children) doesn't try to fall back to anything — the +// tracker keeps the original workDir and just emits no git data. +func TestWorkspaceTracker_NoFallbackWhenNoGitChild(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "not-a-repo"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + wt := NewWorkspaceTracker(tmp, newTestLogger(t)) + if wt.workDir != tmp { + t.Errorf("expected workDir unchanged when no git child exists; got %q", wt.workDir) + } +}