Merge pull request #12 from aoagents/feat/9
Add git-worktree Workspace adapter
This commit is contained in:
commit
0f57a19dd7
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue