Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions backend/internal/adapters/workspace/gitworktree/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type Workspace struct {
type commandRunner func(ctx context.Context, binary string, args ...string) ([]byte, error)

var _ ports.Workspace = (*Workspace)(nil)
var _ ports.WorkspaceProject = (*Workspace)(nil)

// New builds a gitworktree Workspace, validating that ManagedRoot and
// RepoResolver are set and resolving the root to an absolute, symlink-free path.
Expand Down Expand Up @@ -143,6 +144,115 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
}

// CreateWorkspaceProject materialises a root-as-repo workspace session: the
// parent repo worktree is created at the session root, then each registered
// child repo is created at its relative path inside that root. All repos share
// one branch name; if the requested branch already exists in any repo, one
// suffixed branch that is free in every repo is selected and used everywhere.
func (w *Workspace) CreateWorkspaceProject(ctx context.Context, cfg ports.WorkspaceProjectConfig) (ports.WorkspaceProjectInfo, error) {
if err := validateWorkspaceProjectConfig(cfg); err != nil {
return ports.WorkspaceProjectInfo{}, err
}
rootRepo, err := physicalAbs(cfg.RootRepoPath)
if err != nil {
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: root repo path: %w", err)
}
rootPath, err := w.managedPath(ports.WorkspaceConfig{
ProjectID: cfg.ProjectID,
SessionID: cfg.SessionID,
Kind: cfg.Kind,
SessionPrefix: cfg.SessionPrefix,
Branch: firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)),
})
if err != nil {
return ports.WorkspaceProjectInfo{}, err
}
repos := make([]workspaceProjectRepo, 0, len(cfg.Repos)+1)
repos = append(repos, workspaceProjectRepo{
name: domain.RootWorkspaceRepoName,
repoPath: rootRepo,
outputPath: rootPath,
baseBranch: cfg.BaseBranch,
})
for _, child := range cfg.Repos {
repoPath, err := physicalAbs(child.RepoPath)
if err != nil {
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q path: %w", child.Name, err)
}
rel, err := cleanRelativePath(child.RelativePath)
if err != nil {
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q: %w", child.Name, err)
}
outPath, err := w.validateManagedPath(filepath.Join(rootPath, filepath.FromSlash(rel)))
if err != nil {
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q path: %w", child.Name, err)
}
repos = append(repos, workspaceProjectRepo{
name: child.Name,
relativePath: rel,
repoPath: repoPath,
outputPath: outPath,
baseBranch: firstNonEmpty(child.BaseBranch, cfg.BaseBranch),
})
}
branch, err := w.workspaceProjectBranch(ctx, repos, firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)))
if err != nil {
return ports.WorkspaceProjectInfo{}, err
}
created := make([]workspaceProjectRepo, 0, len(repos))
out := ports.WorkspaceProjectInfo{Worktrees: make([]ports.WorkspaceRepoInfo, 0, len(repos))}
for _, repo := range repos {
baseSHA, err := w.createWorkspaceProjectRepo(ctx, repo, branch)
if err != nil {
for i := len(created) - 1; i >= 0; i-- {
_ = w.forceDestroyPath(ctx, created[i].repoPath, created[i].outputPath)
}
return ports.WorkspaceProjectInfo{}, err
}
created = append(created, repo)
info := ports.WorkspaceRepoInfo{
RepoName: repo.name,
RepoPath: repo.repoPath,
Path: repo.outputPath,
Branch: branch,
BaseSHA: baseSHA,
SessionID: cfg.SessionID,
ProjectID: cfg.ProjectID,
RelativePath: repo.relativePath,
}
out.Worktrees = append(out.Worktrees, info)
if repo.name == domain.RootWorkspaceRepoName {
out.Root = ports.WorkspaceInfo{Path: repo.outputPath, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}
}
}
return out, nil
}

// DestroyWorkspaceProject removes every worktree in a workspace project,
// children first and the parent/root last. It uses the same force path as spawn
// rollback because normal interactive cleanup still goes through Destroy and
// the full dirty-preserve matrix is implemented separately.
func (w *Workspace) DestroyWorkspaceProject(ctx context.Context, info ports.WorkspaceProjectInfo) error {
var firstErr error
for i := len(info.Worktrees) - 1; i >= 0; i-- {
wt := info.Worktrees[i]
if wt.Path == "" {
continue
}
repoPath := wt.RepoPath
if repoPath == "" {
if firstErr == nil {
firstErr = fmt.Errorf("gitworktree: missing repo path for worktree %q", wt.Path)
}
continue
}
if err := w.forceDestroyPath(ctx, repoPath, wt.Path); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}

// Destroy removes the session's worktree and prunes it from the repo, refusing
// (rather than force-deleting) if git still has the path registered afterwards.
func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error {
Expand Down Expand Up @@ -507,6 +617,95 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBra
return nil
}

type workspaceProjectRepo struct {
name string
relativePath string
repoPath string
outputPath string
baseBranch string
}

func (w *Workspace) workspaceProjectBranch(ctx context.Context, repos []workspaceProjectRepo, requested string) (string, error) {
branch := strings.TrimSpace(requested)
if branch == "" {
return "", errors.New("gitworktree: branch is required")
}
for i := 0; i < 100; i++ {
candidate := branch
if i > 0 {
candidate = fmt.Sprintf("%s-%d", branch, i+1)
}
free, err := w.workspaceProjectBranchFree(ctx, repos, candidate)
if err != nil {
return "", err
}
if free {
return candidate, nil
}
}
return "", fmt.Errorf("gitworktree: could not find free workspace branch for %q", branch)
}

func (w *Workspace) workspaceProjectBranchFree(ctx context.Context, repos []workspaceProjectRepo, branch string) (bool, error) {
for _, repo := range repos {
if err := w.validateBranch(ctx, repo.repoPath, branch); err != nil {
return false, err
}
exists, err := w.refExists(ctx, repo.repoPath, "refs/heads/"+branch)
if err != nil {
return false, err
}
if exists {
return false, nil
}
records, err := w.listRecords(ctx, repo.repoPath)
if err != nil {
return false, err
}
if conflict, ok := findWorktreeByBranch(records, branch); ok && filepath.Clean(conflict.Path) != filepath.Clean(repo.outputPath) {
return false, nil
}
}
return true, nil
}

func (w *Workspace) createWorkspaceProjectRepo(ctx context.Context, repo workspaceProjectRepo, branch string) (string, error) {
baseRef, err := w.resolveBaseRef(ctx, repo.repoPath, branch, repo.baseBranch)
if err != nil {
if errors.Is(err, errNoBaseRef) {
return "", fmt.Errorf("%w: %q has no local head, no remote, and no tag — run `git fetch` then retry", ErrBranchNotFetched, branch)
}
return "", err
}
baseSHA, err := w.revParse(ctx, repo.repoPath, baseRef)
if err != nil {
return "", err
}
if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo.repoPath, branch, repo.outputPath, baseRef)...); err != nil {
return "", fmt.Errorf("gitworktree: workspace repo %q worktree add branch %q from %q: %w", repo.name, branch, baseRef, err)
}
return baseSHA, nil
}

func (w *Workspace) forceDestroyPath(ctx context.Context, repo, path string) error {
_, _ = w.run(ctx, w.binary, worktreeForceRemoveArgs(repo, path)...)
if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil {
return fmt.Errorf("gitworktree: worktree prune: %w", err)
}
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("gitworktree: force remove path %q: %w", path, err)
}
return nil
}

func (w *Workspace) revParse(ctx context.Context, repo, ref string) (string, error) {
out, err := w.run(ctx, w.binary, "-C", repo, "rev-parse", "--verify", ref)
if err != nil {
return "", fmt.Errorf("gitworktree: rev-parse %q: %w", ref, err)
}
return strings.TrimSpace(string(out)), 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("%w: %q (%w)", ErrBranchInvalid, branch, err)
Expand Down Expand Up @@ -649,6 +848,37 @@ func validateConfig(cfg ports.WorkspaceConfig) error {
return nil
}

func validateWorkspaceProjectConfig(cfg ports.WorkspaceProjectConfig) error {
if err := validateConfig(ports.WorkspaceConfig{
ProjectID: cfg.ProjectID,
SessionID: cfg.SessionID,
Kind: cfg.Kind,
SessionPrefix: cfg.SessionPrefix,
Branch: firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)),
BaseBranch: cfg.BaseBranch,
}); err != nil {
return err
}
if strings.TrimSpace(cfg.RootRepoPath) == "" {
return errors.New("gitworktree: root repo path is required")
}
for _, repo := range cfg.Repos {
if strings.TrimSpace(repo.Name) == "" {
return errors.New("gitworktree: child repo name is required")
}
if err := validatePathComponent("child repo name", repo.Name); err != nil {
return err
}
if strings.TrimSpace(repo.RepoPath) == "" {
return fmt.Errorf("gitworktree: child repo %q path is required", repo.Name)
}
if _, err := cleanRelativePath(repo.RelativePath); err != nil {
return fmt.Errorf("gitworktree: child repo %q: %w", repo.Name, err)
}
}
return nil
}

// validatePathComponent rejects id values that could escape the managed root
// once joined into a path. filepath.Join cleans `..` before validateManagedPath
// runs, so a session id of "../other" would otherwise resolve back inside
Expand Down Expand Up @@ -688,6 +918,34 @@ func resolvedSessionPrefix(cfg ports.WorkspaceConfig) string {
return id[:12]
}

func defaultSessionBranchName(id domain.SessionID) string {
return "ao/" + string(id)
}

func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}

func cleanRelativePath(path string) (string, error) {
rel := filepath.ToSlash(strings.TrimSpace(path))
if rel == "" {
return "", errors.New("relative path is required")
}
if strings.HasPrefix(rel, "/") {
return "", fmt.Errorf("%w: relative path %q must not be absolute", ErrUnsafePath, path)
}
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel)))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%w: relative path %q escapes the workspace root", ErrUnsafePath, path)
}
return clean, nil
}

func (w *Workspace) validateManagedPath(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("%w: empty path", ErrUnsafePath)
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/ports/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ type Workspace interface {
ApplyPreserved(ctx context.Context, info WorkspaceInfo, ref string) error
}

// WorkspaceProject is an optional extension for projects composed from a
// root-as-repo parent plus child repositories. It materialises the parent
// worktree at the session root and each child repo at its registered relative
// path inside that root.
type WorkspaceProject interface {
CreateWorkspaceProject(ctx context.Context, cfg WorkspaceProjectConfig) (WorkspaceProjectInfo, error)
DestroyWorkspaceProject(ctx context.Context, info WorkspaceProjectInfo) error
}

// Workspace-level sentinels surfaced through Create/Restore/Destroy so callers
// can map them to typed errors rather than collapsing every adapter failure
// into an opaque 500. Adapters wrap these via fmt.Errorf("...: %w", sentinel).
Expand Down Expand Up @@ -187,3 +196,45 @@ type WorkspaceInfo struct {
SessionID domain.SessionID
ProjectID domain.ProjectID
}

// WorkspaceProjectConfig describes a multi-repo workspace session. RootRepoPath
// and child RepoPath values are absolute paths to the canonical repositories.
type WorkspaceProjectConfig struct {
ProjectID domain.ProjectID
SessionID domain.SessionID
Kind domain.SessionKind
SessionPrefix string
Branch string
RootRepoPath string
BaseBranch string
Repos []WorkspaceProjectRepoConfig
}

// WorkspaceProjectRepoConfig describes one registered child repo in a
// workspace project session.
type WorkspaceProjectRepoConfig struct {
Name string
RelativePath string
RepoPath string
BaseBranch string
}

// WorkspaceProjectInfo returns the root worktree plus every child worktree.
// Worktrees are ordered root first, then children in creation order.
type WorkspaceProjectInfo struct {
Root WorkspaceInfo
Worktrees []WorkspaceRepoInfo
}

// WorkspaceRepoInfo describes one materialized repo worktree in a workspace
// project session.
type WorkspaceRepoInfo struct {
RepoName string
RepoPath string
Path string
Branch string
BaseSHA string
SessionID domain.SessionID
ProjectID domain.ProjectID
RelativePath string
}
22 changes: 19 additions & 3 deletions backend/internal/processalive/process_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,32 @@
package processalive

import (
"bytes"
"errors"
"os/exec"
"strconv"
"syscall"
)

// Alive reports whether pid exists. EPERM counts as alive: the process exists
// even if the current user cannot signal it.
// Alive reports whether pid maps to a running process. EPERM counts as alive:
// the process exists even if the current user cannot signal it. Zombies are
// treated as not alive because the executable has already exited; only its
// parent has not reaped the process table entry yet.
func Alive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
if err != nil && !errors.Is(err, syscall.EPERM) {
return false
}
return !isZombie(pid)
}

func isZombie(pid int) bool {
out, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false
}
return bytes.HasPrefix(bytes.TrimSpace(out), []byte("Z"))
}
Loading
Loading