feat: add git worktree workspace adapter

This commit is contained in:
harshitsinghbhandari 2026-05-27 17:12:40 +05:30
parent cdfd97cb98
commit 9df3fb59bf
5 changed files with 874 additions and 0 deletions

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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)
}
}

View File

@ -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)
}