From 9df3fb59bf06f35e90fe92a78e060df4b16d82ba Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 17:12:40 +0530 Subject: [PATCH] feat: add git worktree workspace adapter --- .../workspace/gitworktree/commands.go | 40 ++ .../adapters/workspace/gitworktree/parse.go | 75 +++ .../workspace/gitworktree/workspace.go | 426 ++++++++++++++++++ .../gitworktree/workspace_integration_test.go | 146 ++++++ .../workspace/gitworktree/workspace_test.go | 187 ++++++++ 5 files changed, 874 insertions(+) create mode 100644 backend/internal/adapters/workspace/gitworktree/commands.go create mode 100644 backend/internal/adapters/workspace/gitworktree/parse.go create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace.go create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go create mode 100644 backend/internal/adapters/workspace/gitworktree/workspace_test.go diff --git a/backend/internal/adapters/workspace/gitworktree/commands.go b/backend/internal/adapters/workspace/gitworktree/commands.go new file mode 100644 index 000000000..739616c94 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/commands.go @@ -0,0 +1,40 @@ +package gitworktree + +func checkRefFormatBranchArgs(repo, branch string) []string { + return []string{"-C", repo, "check-ref-format", "--branch", branch} +} + +func revParseVerifyArgs(repo, ref string) []string { + return []string{"-C", repo, "rev-parse", "--verify", "--quiet", ref} +} + +func worktreeAddBranchArgs(repo, path, branch string) []string { + return []string{"-C", repo, "worktree", "add", path, branch} +} + +func worktreeAddNewBranchArgs(repo, branch, path, baseRef string) []string { + return []string{"-C", repo, "worktree", "add", "-b", branch, path, baseRef} +} + +func worktreeRemoveForceArgs(repo, path string) []string { + return []string{"-C", repo, "worktree", "remove", "--force", path} +} + +func worktreePruneArgs(repo string) []string { + return []string{"-C", repo, "worktree", "prune"} +} + +func worktreeListPorcelainArgs(repo string) []string { + return []string{"-C", repo, "worktree", "list", "--porcelain"} +} + +func baseRefCandidates(branch, defaultBranch string) []string { + return []string{"origin/" + branch, "origin/" + defaultBranch, branch} +} + +func chooseWorktreeAddArgs(repo, path, branch, baseRef string, localBranchExists bool) []string { + if localBranchExists { + return worktreeAddBranchArgs(repo, path, branch) + } + return worktreeAddNewBranchArgs(repo, branch, path, baseRef) +} diff --git a/backend/internal/adapters/workspace/gitworktree/parse.go b/backend/internal/adapters/workspace/gitworktree/parse.go new file mode 100644 index 000000000..5b2947ba9 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/parse.go @@ -0,0 +1,75 @@ +package gitworktree + +import ( + "bufio" + "strings" +) + +type worktreeRecord struct { + Path string + Branch string + Head string + Bare bool + Detached bool + Locked bool + Prunable bool +} + +func parseWorktreePorcelain(out string) ([]worktreeRecord, error) { + var records []worktreeRecord + var cur *worktreeRecord + + flush := func() { + if cur != nil && cur.Path != "" { + records = append(records, *cur) + } + cur = nil + } + + s := bufio.NewScanner(strings.NewReader(out)) + for s.Scan() { + line := strings.TrimRight(s.Text(), "\r") + if line == "" { + flush() + continue + } + key, val, hasValue := strings.Cut(line, " ") + switch key { + case "worktree": + flush() + cur = &worktreeRecord{} + if hasValue { + cur.Path = val + } + case "HEAD": + if cur != nil && hasValue { + cur.Head = val + } + case "branch": + if cur != nil && hasValue { + cur.Branch = strings.TrimPrefix(val, "refs/heads/") + } + case "bare": + if cur != nil { + cur.Bare = true + } + case "detached": + if cur != nil { + cur.Detached = true + } + case "locked": + if cur != nil { + cur.Locked = true + } + case "prunable": + if cur != nil { + cur.Prunable = true + } + } + } + if err := s.Err(); err != nil { + return nil, err + } + flush() + return records, nil +} diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go new file mode 100644 index 000000000..e90db12c9 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -0,0 +1,426 @@ +package gitworktree + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + defaultGitBinary = "git" + defaultBranch = "main" +) + +var ( + ErrUnsafePath = errors.New("gitworktree: unsafe workspace path") +) + +type RepoResolver interface { + RepoPath(projectID domain.ProjectID) (string, error) +} + +type StaticRepoResolver map[domain.ProjectID]string + +func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) { + path := r[projectID] + if path == "" { + return "", fmt.Errorf("gitworktree: no repo configured for project %q", projectID) + } + return path, nil +} + +type Options struct { + Binary string + ManagedRoot string + DefaultBranch string + RepoResolver RepoResolver +} + +type Workspace struct { + binary string + managedRoot string + defaultBranch string + repos RepoResolver + run commandRunner +} + +type commandRunner func(ctx context.Context, binary string, args ...string) ([]byte, error) + +var _ ports.Workspace = (*Workspace)(nil) + +func New(opts Options) (*Workspace, error) { + binary := opts.Binary + if binary == "" { + binary = defaultGitBinary + } + branch := opts.DefaultBranch + if branch == "" { + branch = defaultBranch + } + if opts.ManagedRoot == "" { + return nil, errors.New("gitworktree: ManagedRoot is required") + } + if opts.RepoResolver == nil { + return nil, errors.New("gitworktree: RepoResolver is required") + } + root, err := physicalAbs(opts.ManagedRoot) + if err != nil { + return nil, fmt.Errorf("gitworktree: managed root: %w", err) + } + return &Workspace{ + binary: binary, + managedRoot: filepath.Clean(root), + defaultBranch: branch, + repos: opts.RepoResolver, + run: runCommand, + }, nil +} + +func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if err := validateConfig(cfg); err != nil { + return ports.WorkspaceInfo{}, err + } + repo, err := w.repoPath(cfg.ProjectID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + path, err := w.managedPath(cfg.ProjectID, cfg.SessionID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} + +func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { + if info.ProjectID == "" { + return errors.New("gitworktree: project id is required") + } + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + repo, err := w.repoPath(info.ProjectID) + if err != nil { + return err + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return err + } + _, removeErr := w.run(ctx, w.binary, worktreeRemoveForceArgs(repo, path)...) + if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return err + } + if worktreeRegistered(records, path) { + if removeErr != nil { + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w)", path, removeErr) + } + return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune", path) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("gitworktree: remove unregistered path %q: %w", path, err) + } + return nil +} + +func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) { + if project == "" { + return nil, errors.New("gitworktree: project id is required") + } + repo, err := w.repoPath(project) + if err != nil { + return nil, err + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return nil, err + } + projectRoot, err := w.projectRoot(project) + if err != nil { + return nil, err + } + return filterProjectWorktrees(records, projectRoot, project), nil +} + +func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if err := validateConfig(cfg); err != nil { + return ports.WorkspaceInfo{}, err + } + repo, err := w.repoPath(cfg.ProjectID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + path, err := w.managedPath(cfg.ProjectID, cfg.SessionID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + records, err := w.listRecords(ctx, repo) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if rec, ok := findWorktree(records, path); ok { + branch := rec.Branch + if branch == "" { + branch = cfg.Branch + } + return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + } + if nonEmpty, err := pathExistsNonEmpty(path); err != nil { + return ports.WorkspaceInfo{}, err + } else if nonEmpty { + return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path) + } + if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil { + return ports.WorkspaceInfo{}, err + } + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} + +func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) error { + localBranch, err := w.refExists(ctx, repo, "refs/heads/"+branch) + if err != nil { + return err + } + if localBranch { + if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, "", true)...); err != nil { + return fmt.Errorf("gitworktree: worktree add existing branch %q: %w", branch, err) + } + return nil + } + baseRef, err := w.resolveBaseRef(ctx, repo, branch) + if err != nil { + return err + } + if _, err := w.run(ctx, w.binary, chooseWorktreeAddArgs(repo, path, branch, baseRef, false)...); err != nil { + return fmt.Errorf("gitworktree: worktree add branch %q from %q: %w", branch, baseRef, err) + } + return nil +} + +func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) error { + if _, err := w.run(ctx, w.binary, checkRefFormatBranchArgs(repo, branch)...); err != nil { + return fmt.Errorf("gitworktree: invalid branch %q: %w", branch, err) + } + return nil +} + +func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch string) (string, error) { + candidates := baseRefCandidates(branch, w.defaultBranch) + for _, ref := range candidates { + exists, err := w.refExists(ctx, repo, ref) + if err != nil { + return "", err + } + if exists { + return ref, nil + } + } + return "", fmt.Errorf("gitworktree: no base ref found for branch %q (tried %s)", branch, strings.Join(candidates, ", ")) +} + +func (w *Workspace) refExists(ctx context.Context, repo, ref string) (bool, error) { + _, err := w.run(ctx, w.binary, revParseVerifyArgs(repo, ref)...) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("gitworktree: verify ref %q: %w", ref, err) +} + +func (w *Workspace) listRecords(ctx context.Context, repo string) ([]worktreeRecord, error) { + out, err := w.run(ctx, w.binary, worktreeListPorcelainArgs(repo)...) + if err != nil { + return nil, fmt.Errorf("gitworktree: worktree list: %w", err) + } + records, err := parseWorktreePorcelain(string(out)) + if err != nil { + return nil, fmt.Errorf("gitworktree: parse worktree list: %w", err) + } + return records, nil +} + +func (w *Workspace) repoPath(project domain.ProjectID) (string, error) { + repo, err := w.repos.RepoPath(project) + if err != nil { + return "", err + } + if repo == "" { + return "", fmt.Errorf("gitworktree: no repo configured for project %q", project) + } + abs, err := physicalAbs(repo) + if err != nil { + return "", fmt.Errorf("gitworktree: repo path: %w", err) + } + return abs, nil +} + +func physicalAbs(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + abs = filepath.Clean(abs) + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return filepath.Clean(resolved), nil + } + parent := filepath.Dir(abs) + base := filepath.Base(abs) + for parent != "." && parent != string(os.PathSeparator) { + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, base), nil + } + base = filepath.Join(filepath.Base(parent), base) + parent = filepath.Dir(parent) + } + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, base), nil + } + return abs, nil +} + +func validateConfig(cfg ports.WorkspaceConfig) error { + if cfg.ProjectID == "" { + return errors.New("gitworktree: project id is required") + } + if cfg.SessionID == "" { + return errors.New("gitworktree: session id is required") + } + if cfg.Branch == "" { + return errors.New("gitworktree: branch is required") + } + return nil +} + +func (w *Workspace) managedPath(project domain.ProjectID, session domain.SessionID) (string, error) { + path := filepath.Join(w.managedRoot, string(project), string(session)) + return w.validateManagedPath(path) +} + +func (w *Workspace) projectRoot(project domain.ProjectID) (string, error) { + path := filepath.Join(w.managedRoot, string(project)) + return w.validateManagedPath(path) +} + +func (w *Workspace) validateManagedPath(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("%w: empty path", ErrUnsafePath) + } + if !filepath.IsAbs(path) { + return "", fmt.Errorf("%w: %q is not absolute", ErrUnsafePath, path) + } + clean := filepath.Clean(path) + if clean != path { + return "", fmt.Errorf("%w: %q is not clean", ErrUnsafePath, path) + } + physical, err := physicalAbs(clean) + if err != nil { + return "", fmt.Errorf("gitworktree: resolve path %q: %w", path, err) + } + clean = physical + inside, err := pathWithin(w.managedRoot, clean) + if err != nil { + return "", err + } + if !inside || clean == w.managedRoot { + return "", fmt.Errorf("%w: %q is outside managed root %q", ErrUnsafePath, clean, w.managedRoot) + } + return clean, nil +} + +func pathWithin(root, path string) (bool, error) { + rel, err := filepath.Rel(root, path) + if err != nil { + return false, fmt.Errorf("gitworktree: compare paths: %w", err) + } + return rel == "." || (rel != "" && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))), nil +} + +func filterProjectWorktrees(records []worktreeRecord, projectRoot string, project domain.ProjectID) []ports.WorkspaceInfo { + out := make([]ports.WorkspaceInfo, 0, len(records)) + for _, rec := range records { + path := filepath.Clean(rec.Path) + inside, err := pathWithin(projectRoot, path) + if err != nil || !inside || path == projectRoot { + continue + } + out = append(out, ports.WorkspaceInfo{ + Path: path, + Branch: rec.Branch, + SessionID: domain.SessionID(filepath.Base(path)), + ProjectID: project, + }) + } + return out +} + +func worktreeRegistered(records []worktreeRecord, path string) bool { + _, ok := findWorktree(records, path) + return ok +} + +func findWorktree(records []worktreeRecord, path string) (worktreeRecord, bool) { + clean := filepath.Clean(path) + for _, rec := range records { + if filepath.Clean(rec.Path) == clean { + return rec, true + } + } + return worktreeRecord{}, false +} + +func pathExistsNonEmpty(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err == nil { + return len(entries) > 0, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("gitworktree: inspect path %q: %w", path, err) +} + +func runCommand(ctx context.Context, binary string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return out, commandError{args: append([]string{binary}, args...), output: string(out), err: err} + } + return out, nil +} + +type commandError struct { + args []string + output string + err error +} + +func (e commandError) Error() string { + if strings.TrimSpace(e.output) == "" { + return fmt.Sprintf("%s: %v", strings.Join(e.args, " "), e.err) + } + return fmt.Sprintf("%s: %v: %s", strings.Join(e.args, " "), e.err, strings.TrimSpace(e.output)) +} + +func (e commandError) Unwrap() error { return e.err } diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go new file mode 100644 index 000000000..2b435c855 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace_integration_test.go @@ -0,0 +1,146 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestWorkspaceIntegrationCreateListRestoreDestroy(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + cfg := ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/one"} + + info, err := ws.Create(ctx, cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + if info.Path != filepath.Join(ws.managedRoot, "proj", "sess") || info.Branch != cfg.Branch || info.SessionID != cfg.SessionID || info.ProjectID != cfg.ProjectID { + t.Fatalf("info = %#v", info) + } + if _, err := os.Stat(filepath.Join(info.Path, "README.md")); err != nil { + t.Fatalf("created worktree missing seed file: %v", err) + } + + listed, err := ws.List(ctx, "proj") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(listed) != 1 || listed[0].Path != info.Path || listed[0].Branch != cfg.Branch || listed[0].SessionID != cfg.SessionID { + t.Fatalf("listed = %#v", listed) + } + + restored, err := ws.Restore(ctx, cfg) + if err != nil { + t.Fatalf("restore registered: %v", err) + } + if restored.Path != info.Path || restored.Branch != cfg.Branch { + t.Fatalf("restored = %#v", restored) + } + + if err := ws.Destroy(ctx, info); err != nil { + t.Fatalf("destroy: %v", err) + } + if _, err := os.Stat(info.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("path after destroy stat err = %v, want not exist", err) + } + + restored, err = ws.Restore(ctx, cfg) + if err != nil { + t.Fatalf("restore after destroy: %v", err) + } + if restored.Path != info.Path || restored.Branch != cfg.Branch { + t.Fatalf("restored after destroy = %#v", restored) + } + if err := ws.Destroy(ctx, restored); err != nil { + t.Fatalf("destroy restored: %v", err) + } +} + +func TestWorkspaceIntegrationDestroyRefusesLockedWorktree(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + root := filepath.Join(tmp, "managed") + ws, err := New(Options{Binary: git, ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ctx := context.Background() + info, err := ws.Create(ctx, ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/lock"}) + if err != nil { + t.Fatalf("create: %v", err) + } + runGit(t, git, repo, "worktree", "lock", info.Path) + + err = ws.Destroy(ctx, info) + if err == nil || !strings.Contains(err.Error(), "still registered") { + t.Fatalf("destroy locked error = %v, want still registered refusal", err) + } + if _, statErr := os.Stat(filepath.Join(info.Path, "README.md")); statErr != nil { + t.Fatalf("locked worktree was not preserved: %v", statErr) + } + + runGit(t, git, repo, "worktree", "unlock", info.Path) + if err := ws.Destroy(ctx, info); err != nil { + t.Fatalf("destroy after unlock: %v", err) + } +} + +func requireGit(t *testing.T) string { + t.Helper() + git, err := exec.LookPath("git") + if err != nil { + t.Skip("git not found") + } + return git +} + +func setupOriginClone(t *testing.T, git, tmp string) string { + t.Helper() + origin := filepath.Join(tmp, "origin.git") + seed := filepath.Join(tmp, "seed") + repo := filepath.Join(tmp, "repo") + run(t, git, "init", "--bare", origin) + run(t, git, "init", seed) + runGit(t, git, seed, "config", "user.email", "ao@example.com") + runGit(t, git, seed, "config", "user.name", "Ao Agents") + if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil { + t.Fatalf("write seed: %v", err) + } + runGit(t, git, seed, "add", "README.md") + runGit(t, git, seed, "commit", "-m", "seed") + runGit(t, git, seed, "branch", "-M", "main") + runGit(t, git, seed, "remote", "add", "origin", origin) + runGit(t, git, seed, "push", "-u", "origin", "main") + run(t, git, "clone", origin, repo) + runGit(t, git, repo, "checkout", "main") + return repo +} + +func runGit(t *testing.T, git, dir string, args ...string) { + t.Helper() + run(t, git, append([]string{"-C", dir}, args...)...) +} + +func run(t *testing.T, binary string, args ...string) { + t.Helper() + cmd := exec.Command(binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", binary, strings.Join(args, " "), err, out) + } +} diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go new file mode 100644 index 000000000..7e56529de --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -0,0 +1,187 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCommandArgs(t *testing.T) { + repo := "/repo" + path := "/managed/proj/sess" + branch := "feature/test" + + cases := []struct { + name string + got []string + want []string + }{ + {"check ref", checkRefFormatBranchArgs(repo, branch), []string{"-C", repo, "check-ref-format", "--branch", branch}}, + {"rev parse", revParseVerifyArgs(repo, "origin/main"), []string{"-C", repo, "rev-parse", "--verify", "--quiet", "origin/main"}}, + {"add existing", chooseWorktreeAddArgs(repo, path, branch, "", true), []string{"-C", repo, "worktree", "add", path, branch}}, + {"add new", chooseWorktreeAddArgs(repo, path, branch, "origin/main", false), []string{"-C", repo, "worktree", "add", "-b", branch, path, "origin/main"}}, + {"remove", worktreeRemoveForceArgs(repo, path), []string{"-C", repo, "worktree", "remove", "--force", path}}, + {"prune", worktreePruneArgs(repo), []string{"-C", repo, "worktree", "prune"}}, + {"list", worktreeListPorcelainArgs(repo), []string{"-C", repo, "worktree", "list", "--porcelain"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !reflect.DeepEqual(tc.got, tc.want) { + t.Fatalf("args = %#v, want %#v", tc.got, tc.want) + } + }) + } +} + +func TestBaseRefCandidates(t *testing.T) { + got := baseRefCandidates("feature/test", "main") + want := []string{"origin/feature/test", "origin/main", "feature/test"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("candidates = %#v, want %#v", got, want) + } +} + +func TestParseWorktreePorcelain(t *testing.T) { + input := strings.Join([]string{ + "worktree /repo", + "HEAD abc123", + "branch refs/heads/main", + "", + "worktree /managed/proj/sess1", + "HEAD def456", + "branch refs/heads/feature/test", + "", + "worktree /managed/proj/sess2", + "HEAD 789abc", + "detached", + "", + "worktree /bare", + "bare", + "", + }, "\n") + + recs, err := parseWorktreePorcelain(input) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(recs) != 4 { + t.Fatalf("len = %d, want 4: %#v", len(recs), recs) + } + if recs[1].Path != "/managed/proj/sess1" || recs[1].Branch != "feature/test" { + t.Fatalf("normal record = %#v", recs[1]) + } + if !recs[2].Detached || recs[2].Branch != "" { + t.Fatalf("detached record = %#v", recs[2]) + } + if !recs[3].Bare { + t.Fatalf("bare record = %#v", recs[3]) + } +} + +func TestFilterProjectWorktrees(t *testing.T) { + root := filepath.Clean("/managed/proj") + recs := []worktreeRecord{ + {Path: "/repo", Branch: "main"}, + {Path: "/managed/proj/s1", Branch: "feature/one"}, + {Path: "/managed/proj/s2", Branch: ""}, + {Path: "/managed/other/s3", Branch: "feature/three"}, + } + got := filterProjectWorktrees(recs, root, domain.ProjectID("proj")) + if len(got) != 2 { + t.Fatalf("len = %d, want 2: %#v", len(got), got) + } + if got[0].SessionID != "s1" || got[0].Branch != "feature/one" || got[0].ProjectID != "proj" { + t.Fatalf("first = %#v", got[0]) + } + if got[1].SessionID != "s2" || got[1].Branch != "" { + t.Fatalf("second = %#v", got[1]) + } +} + +func TestManagedPathSafety(t *testing.T) { + root := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path, err := ws.managedPath("proj", "sess") + if err != nil { + t.Fatalf("managed path: %v", err) + } + if want := filepath.Join(ws.managedRoot, "proj", "sess"); path != want { + t.Fatalf("path = %q, want %q", path, want) + } + if _, err := ws.validateManagedPath(filepath.Join(root, "..", "outside")); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("outside error = %v, want ErrUnsafePath", err) + } + if _, err := ws.validateManagedPath("relative/path"); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("relative error = %v, want ErrUnsafePath", err) + } +} + +func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ws.run = func(context.Context, string, ...string) ([]byte, error) { + return []byte("worktree " + repo + "\nbranch refs/heads/main\n"), nil + } + path := filepath.Join(ws.managedRoot, "proj", "sess") + if err := mkdirFile(path, "keep.txt"); err != nil { + t.Fatalf("seed path: %v", err) + } + _, err = ws.Restore(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/one"}) + if err == nil || !strings.Contains(err.Error(), "path exists and is not a registered worktree") { + t.Fatalf("restore error = %v", err) + } +} + +func TestDestroyRefusesStillRegisteredPathAndPreservesDirectory(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path := filepath.Join(ws.managedRoot, "proj", "sess") + if err := mkdirFile(path, "keep.txt"); err != nil { + t.Fatalf("seed path: %v", err) + } + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "worktree remove"): + return []byte("locked"), errors.New("remove failed") + case strings.Contains(joined, "worktree prune"): + return nil, nil + case strings.Contains(joined, "worktree list --porcelain"): + return []byte("worktree " + path + "\nbranch refs/heads/feature/one\n"), nil + default: + return nil, nil + } + } + err = ws.Destroy(context.Background(), ports.WorkspaceInfo{Path: path, ProjectID: "proj", SessionID: "sess", Branch: "feature/one"}) + if err == nil || !strings.Contains(err.Error(), "still registered") { + t.Fatalf("destroy error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(path, "keep.txt")); statErr != nil { + t.Fatalf("expected directory to be preserved: %v", statErr) + } +} + +func mkdirFile(dir, name string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, name), []byte("data"), 0o644) +}