fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots
The "Changes" panel was empty for multi-repo tasks because the agent's workspace path is the task root — a plain holder directory that holds one git worktree per repository as siblings. The workspace tracker tried to run git commands against that bare root and silently failed, so no git status updates ever reached the UI. The tracker now detects this at construction: when the configured workDir isn't itself a git repo, it falls back to the first immediate child directory that is. The user gets the primary repo's changes in the panel; cross-repo aggregation is a follow-up. - preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping hidden / non-directory entries) and returns the first that has a valid git index. Logs an info line when the rewrite happens so it's visible in the agentctl log. - Repo-less tasks (e.g. quick chat) and single-repo tasks keep their previous behavior verbatim — the fallback only fires when the root itself fails resolveGitIndexPath. - Tests cover: fallback when the workdir is a multi-repo holder, no-fallback for a real git repo, no-fallback when no git child exists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user