refactor: reuse workspace lifecycle for child repos

This commit is contained in:
Aditi Chauhan 2026-07-01 14:09:53 +05:30
parent fa71a7dda5
commit 5ccaf3afd7
5 changed files with 124 additions and 238 deletions

View File

@ -253,153 +253,13 @@ func (w *Workspace) DestroyWorkspaceProject(ctx context.Context, info ports.Work
return firstErr
}
// DestroyWorkspaceProjectWorktree removes one repo worktree from a workspace
// project using that repo's canonical path. It preserves the same dirty-worktree
// refusal contract as Destroy.
func (w *Workspace) DestroyWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) error {
if info.RepoPath == "" {
return fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path)
}
if info.Path == "" {
return fmt.Errorf("%w: empty path", ErrUnsafePath)
}
repo, err := physicalAbs(info.RepoPath)
if err != nil {
return fmt.Errorf("gitworktree: repo path: %w", err)
}
path, err := w.validateManagedPath(info.Path)
if err != nil {
return err
}
_, removeErr := w.run(ctx, w.binary, worktreeRemoveArgs(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 _, ok := findWorktree(records, path); ok {
if removeErr != nil {
dirty, statusErr := w.isDirty(ctx, path)
if statusErr == nil && dirty {
return fmt.Errorf("gitworktree: refusing to remove %q: %w (worktree remove: %w)", path, ports.ErrWorkspaceDirty, removeErr)
}
if statusErr != nil {
return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w; dirty probe: %w)", path, removeErr, statusErr)
}
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
}
// ForceDestroyWorkspaceProjectWorktree force-removes one repo worktree after
// its work has been preserved.
func (w *Workspace) ForceDestroyWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) error {
if info.RepoPath == "" {
return fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path)
}
if info.Path == "" {
return fmt.Errorf("%w: empty path", ErrUnsafePath)
}
repo, err := physicalAbs(info.RepoPath)
if err != nil {
return fmt.Errorf("gitworktree: repo path: %w", err)
}
path, err := w.validateManagedPath(info.Path)
if err != nil {
return err
}
return w.forceDestroyPath(ctx, repo, path)
}
// RestoreWorkspaceProjectWorktree re-attaches one repo worktree for a workspace
// project using the repo path captured from project registration.
func (w *Workspace) RestoreWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) {
if info.RepoPath == "" {
return ports.WorkspaceRepoInfo{}, fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path)
}
if info.Path == "" {
return ports.WorkspaceRepoInfo{}, fmt.Errorf("%w: empty path", ErrUnsafePath)
}
if info.Branch == "" {
return ports.WorkspaceRepoInfo{}, errors.New("gitworktree: branch is required")
}
repo, err := physicalAbs(info.RepoPath)
if err != nil {
return ports.WorkspaceRepoInfo{}, fmt.Errorf("gitworktree: repo path: %w", err)
}
path, err := w.validateManagedPath(info.Path)
if err != nil {
return ports.WorkspaceRepoInfo{}, err
}
records, err := w.listRecords(ctx, repo)
if err != nil {
return ports.WorkspaceRepoInfo{}, err
}
out := info
if rec, ok := findWorktree(records, path); ok {
if rec.Branch != "" {
out.Branch = rec.Branch
}
out.Path = path
out.RepoPath = repo
return out, nil
}
if nonEmpty, err := pathExistsNonEmpty(path); err != nil {
return ports.WorkspaceRepoInfo{}, err
} else if nonEmpty {
if _, err := moveStrayPathAside(path); err != nil {
return ports.WorkspaceRepoInfo{}, err
}
}
if err := w.validateBranch(ctx, repo, info.Branch); err != nil {
return ports.WorkspaceRepoInfo{}, err
}
if err := w.addWorktree(ctx, repo, path, info.Branch, ""); err != nil {
return ports.WorkspaceRepoInfo{}, err
}
out.Path = path
out.RepoPath = repo
return out, nil
}
// StashWorkspaceProjectWorktree captures uncommitted work for one workspace
// project repo worktree.
func (w *Workspace) StashWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) (string, error) {
return w.StashUncommitted(ctx, workspaceInfoFromRepoInfo(info))
}
// ApplyWorkspaceProjectPreserved replays a preserve ref into one workspace
// project repo worktree.
func (w *Workspace) ApplyWorkspaceProjectPreserved(ctx context.Context, info ports.WorkspaceRepoInfo, ref string) error {
return w.ApplyPreserved(ctx, workspaceInfoFromRepoInfo(info), ref)
}
func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo {
return ports.WorkspaceInfo{
Path: info.Path,
Branch: info.Branch,
SessionID: info.SessionID,
ProjectID: info.ProjectID,
}
}
// 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 {
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)
repo, err := w.repoPathForInfo(info)
if err != nil {
return err
}
@ -448,13 +308,10 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error
// discards agent work. For interactive teardown (ao session kill, ao cleanup)
// use Destroy, which refuses dirty worktrees via ErrWorkspaceDirty.
func (w *Workspace) ForceDestroy(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)
repo, err := w.repoPathForInfo(info)
if err != nil {
return err
}
@ -664,11 +521,11 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por
if err := validateConfig(cfg); err != nil {
return ports.WorkspaceInfo{}, err
}
repo, err := w.repoPath(cfg.ProjectID)
repo, err := w.repoPathForConfig(cfg)
if err != nil {
return ports.WorkspaceInfo{}, err
}
path, err := w.managedPath(cfg)
path, err := w.restorePath(cfg)
if err != nil {
return ports.WorkspaceInfo{}, err
}
@ -681,12 +538,17 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por
if branch == "" {
branch = cfg.Branch
}
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, 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 cfg.Path == "" {
return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path)
}
if _, err := moveStrayPathAside(path); err != nil {
return ports.WorkspaceInfo{}, err
}
}
if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil {
return ports.WorkspaceInfo{}, err
@ -694,7 +556,7 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
return ports.WorkspaceInfo{}, err
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, nil
}
func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, bool, error) {
@ -935,6 +797,31 @@ func (w *Workspace) repoPath(project domain.ProjectID) (string, error) {
return abs, nil
}
func (w *Workspace) repoPathForInfo(info ports.WorkspaceInfo) (string, error) {
if info.RepoPath != "" {
repo, err := physicalAbs(info.RepoPath)
if err != nil {
return "", fmt.Errorf("gitworktree: repo path: %w", err)
}
return repo, nil
}
if info.ProjectID == "" {
return "", errors.New("gitworktree: project id is required")
}
return w.repoPath(info.ProjectID)
}
func (w *Workspace) repoPathForConfig(cfg ports.WorkspaceConfig) (string, error) {
if cfg.RepoPath != "" {
repo, err := physicalAbs(cfg.RepoPath)
if err != nil {
return "", fmt.Errorf("gitworktree: repo path: %w", err)
}
return repo, nil
}
return w.repoPath(cfg.ProjectID)
}
func physicalAbs(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
@ -1042,6 +929,13 @@ func (w *Workspace) managedPath(cfg ports.WorkspaceConfig) (string, error) {
return w.validateManagedPath(path)
}
func (w *Workspace) restorePath(cfg ports.WorkspaceConfig) (string, error) {
if cfg.Path != "" {
return w.validateManagedPath(cfg.Path)
}
return w.managedPath(cfg)
}
// resolvedSessionPrefix returns cfg.SessionPrefix when set, otherwise the first
// 12 characters of the project ID (matching the display-prefix convention).
func resolvedSessionPrefix(cfg ports.WorkspaceConfig) string {

View File

@ -282,7 +282,7 @@ func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) {
}
}
func TestRestoreWorkspaceProjectWorktreeMovesStrayPathAside(t *testing.T) {
func TestRestoreWithRepoPathMovesStrayPathAside(t *testing.T) {
root := t.TempDir()
repo := t.TempDir()
ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}})
@ -317,16 +317,15 @@ func TestRestoreWorkspaceProjectWorktreeMovesStrayPathAside(t *testing.T) {
}
}
info, err := ws.RestoreWorkspaceProjectWorktree(context.Background(), ports.WorkspaceRepoInfo{
RepoName: "api",
info, err := ws.Restore(context.Background(), ports.WorkspaceConfig{
ProjectID: "proj",
SessionID: "proj-1",
Branch: "ao/proj-1",
RepoPath: repo,
Path: path,
Branch: "ao/proj-1",
SessionID: "proj-1",
ProjectID: "proj",
})
if err != nil {
t.Fatalf("RestoreWorkspaceProjectWorktree: %v", err)
t.Fatalf("Restore: %v", err)
}
if info.Path != path || addPath != path {
t.Fatalf("restored path=%q addPath=%q, want %q", info.Path, addPath, path)

View File

@ -148,11 +148,6 @@ type Workspace interface {
type WorkspaceProject interface {
CreateWorkspaceProject(ctx context.Context, cfg WorkspaceProjectConfig) (WorkspaceProjectInfo, error)
DestroyWorkspaceProject(ctx context.Context, info WorkspaceProjectInfo) error
DestroyWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) error
ForceDestroyWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) error
RestoreWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) (WorkspaceRepoInfo, error)
StashWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) (ref string, err error)
ApplyWorkspaceProjectPreserved(ctx context.Context, info WorkspaceRepoInfo, ref string) error
}
// Workspace-level sentinels surfaced through Create/Restore/Destroy so callers
@ -195,6 +190,10 @@ type WorkspaceConfig struct {
// BaseBranch is the per-project default branch new session branches are
// created from. Empty falls back to the workspace adapter's own default.
BaseBranch string
// RepoPath optionally overrides ProjectID-based repo resolution.
RepoPath string
// Path optionally supplies an existing managed worktree path for restore.
Path string
}
// WorkspaceInfo describes a created workspace — where it lives and its branch.
@ -203,6 +202,10 @@ type WorkspaceInfo struct {
Branch string
SessionID domain.SessionID
ProjectID domain.ProjectID
// RepoPath optionally overrides ProjectID-based repo resolution. It is used
// when the normal workspace lifecycle primitives operate on one child repo
// inside a workspace project.
RepoPath string
}
// WorkspaceProjectConfig describes a multi-repo workspace session. RootRepoPath

View File

@ -1027,12 +1027,8 @@ func (m *Manager) sessionWorktreeRowsToRepoInfos(ctx context.Context, project do
}
func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domain.SessionRecord, rows []ports.WorkspaceRepoInfo, destroyRuntime bool) error {
adapter, ok := m.workspace.(ports.WorkspaceProject)
if !ok {
return errors.New("workspace project lifecycle is not supported by workspace adapter")
}
for _, row := range rows {
ref, err := adapter.StashWorkspaceProjectWorktree(ctx, row)
ref, err := m.workspace.StashUncommitted(ctx, workspaceInfoFromRepoInfo(row))
if err != nil {
return fmt.Errorf("save %s repo %s: stash: %w", rec.ID, row.RepoName, err)
}
@ -1058,7 +1054,7 @@ func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domai
}
}
for i := len(rows) - 1; i >= 0; i-- {
if err := adapter.ForceDestroyWorkspaceProjectWorktree(ctx, rows[i]); err != nil {
if err := m.workspace.ForceDestroy(ctx, workspaceInfoFromRepoInfo(rows[i])); err != nil {
m.logger.Warn("save-teardown-all: force destroy failed", "sessionID", rec.ID, "repo", rows[i].RepoName, "error", err)
}
}
@ -1066,23 +1062,20 @@ func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domai
}
func (m *Manager) destroyWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo, preserveDirty bool) (bool, error) {
adapter, ok := m.workspace.(ports.WorkspaceProject)
if !ok {
return false, errors.New("workspace project lifecycle is not supported by workspace adapter")
}
cleaned := false
var firstErr error
for i := len(rows) - 1; i >= 0; i-- {
if rows[i].Path == "" {
continue
}
if err := adapter.DestroyWorkspaceProjectWorktree(ctx, rows[i]); err != nil {
info := workspaceInfoFromRepoInfo(rows[i])
if err := m.workspace.Destroy(ctx, info); err != nil {
preservedRef := ""
if errors.Is(err, ports.ErrWorkspaceDirty) {
if !preserveDirty {
return cleaned, err
}
ref, preserveErr := adapter.StashWorkspaceProjectWorktree(ctx, rows[i])
ref, preserveErr := m.workspace.StashUncommitted(ctx, info)
if preserveErr == nil {
preservedRef = ref
if stateErr := m.upsertWorkspaceProjectRowState(ctx, rows[i], "unavailable", ref); stateErr != nil {
@ -1090,7 +1083,7 @@ func (m *Manager) destroyWorkspaceProjectRows(ctx context.Context, rows []ports.
}
}
if preserveErr == nil {
forceErr := adapter.ForceDestroyWorkspaceProjectWorktree(ctx, rows[i])
forceErr := m.workspace.ForceDestroy(ctx, info)
if forceErr == nil {
cleaned = true
continue
@ -1129,18 +1122,22 @@ func (m *Manager) upsertWorkspaceProjectRowState(ctx context.Context, row ports.
}
func (m *Manager) restoreWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) {
adapter, ok := m.workspace.(ports.WorkspaceProject)
if !ok {
return ports.WorkspaceRepoInfo{}, errors.New("workspace project lifecycle is not supported by workspace adapter")
}
var root ports.WorkspaceRepoInfo
for _, row := range rows {
restored, err := adapter.RestoreWorkspaceProjectWorktree(ctx, row)
restored, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{
ProjectID: row.ProjectID,
SessionID: row.SessionID,
Branch: row.Branch,
RepoPath: row.RepoPath,
Path: row.Path,
})
if err != nil {
return ports.WorkspaceRepoInfo{}, fmt.Errorf("repo %s: %w", row.RepoName, err)
}
if restored.RepoName == domain.RootWorkspaceRepoName {
root = restored
row.Path = restored.Path
row.Branch = restored.Branch
if row.RepoName == domain.RootWorkspaceRepoName {
root = row
}
}
if root.Path == "" {
@ -1150,10 +1147,6 @@ func (m *Manager) restoreWorkspaceProjectRows(ctx context.Context, rows []ports.
}
func (m *Manager) applyWorkspaceProjectPreserved(ctx context.Context, rows []ports.WorkspaceRepoInfo) {
adapter, ok := m.workspace.(ports.WorkspaceProject)
if !ok {
return
}
for _, row := range rows {
var preserveRef string
sessionRows, err := m.store.ListSessionWorktrees(ctx, row.SessionID)
@ -1170,7 +1163,7 @@ func (m *Manager) applyWorkspaceProjectPreserved(ctx context.Context, rows []por
if preserveRef == "" {
continue
}
if applyErr := adapter.ApplyWorkspaceProjectPreserved(ctx, row, preserveRef); applyErr != nil {
if applyErr := m.workspace.ApplyPreserved(ctx, workspaceInfoFromRepoInfo(row), preserveRef); applyErr != nil {
if errors.Is(applyErr, ports.ErrPreservedConflict) {
m.logger.Warn("restore-all: apply preserved produced conflicts; agent relaunched with conflict markers in place",
"sessionID", row.SessionID, "repo", row.RepoName, "ref", preserveRef, "error", applyErr)
@ -1670,6 +1663,7 @@ func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo
Branch: info.Branch,
SessionID: info.SessionID,
ProjectID: info.ProjectID,
RepoPath: info.RepoPath,
}
}

View File

@ -333,7 +333,14 @@ func (w *fakeWorkspace) CreateWorkspaceProject(_ context.Context, cfg ports.Work
}
return out, nil
}
func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error {
func (w *fakeWorkspace) Destroy(_ context.Context, info ports.WorkspaceInfo) error {
if info.RepoPath != "" {
entry := "Destroy:" + fakeWorkspaceRepoName(info)
w.calls = append(w.calls, entry)
if w.sharedLog != nil {
*w.sharedLog = append(*w.sharedLog, entry)
}
}
w.destroyed++
return w.destroyErr
}
@ -341,51 +348,23 @@ func (w *fakeWorkspace) DestroyWorkspaceProject(context.Context, ports.Workspace
w.projectDestroyed++
return w.destroyErr
}
func (w *fakeWorkspace) DestroyWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) error {
entry := "DestroyWorkspaceProjectWorktree:" + info.RepoName
w.calls = append(w.calls, entry)
if w.sharedLog != nil {
*w.sharedLog = append(*w.sharedLog, entry)
}
return w.destroyErr
}
func (w *fakeWorkspace) ForceDestroyWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) error {
entry := "ForceDestroyWorkspaceProjectWorktree:" + info.RepoName
w.calls = append(w.calls, entry)
if w.sharedLog != nil {
*w.sharedLog = append(*w.sharedLog, entry)
}
return w.forceDestroyErr
}
func (w *fakeWorkspace) RestoreWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) {
entry := "RestoreWorkspaceProjectWorktree:" + info.RepoName
w.calls = append(w.calls, entry)
return info, nil
}
func (w *fakeWorkspace) StashWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) (string, error) {
w.stashCalls++
entry := "StashWorkspaceProjectWorktree:" + info.RepoName
w.calls = append(w.calls, entry)
if w.sharedLog != nil {
*w.sharedLog = append(*w.sharedLog, entry)
}
if w.stashErr != nil {
return "", w.stashErr
}
if w.stashRef == "" {
return "", nil
}
return w.stashRef + "/" + info.RepoName, nil
}
func (w *fakeWorkspace) ApplyWorkspaceProjectPreserved(_ context.Context, info ports.WorkspaceRepoInfo, ref string) error {
w.calls = append(w.calls, "ApplyWorkspaceProjectPreserved:"+info.RepoName+":"+ref)
return w.applyErr
}
func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
if cfg.RepoPath != "" {
entry := "Restore:" + fakeWorkspaceRepoName(ports.WorkspaceInfo{
Path: cfg.Path,
SessionID: cfg.SessionID,
RepoPath: cfg.RepoPath,
})
w.calls = append(w.calls, entry)
return ports.WorkspaceInfo{Path: cfg.Path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: cfg.RepoPath}, nil
}
return w.Create(ctx, cfg)
}
func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo) error {
entry := "ForceDestroy:" + string(info.SessionID)
if info.RepoPath != "" {
entry = "ForceDestroy:" + fakeWorkspaceRepoName(info)
}
w.calls = append(w.calls, entry)
if w.sharedLog != nil {
*w.sharedLog = append(*w.sharedLog, entry)
@ -395,17 +374,34 @@ func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo
func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) {
w.stashCalls++
entry := "StashUncommitted:" + string(info.SessionID)
if info.RepoPath != "" {
entry = "StashUncommitted:" + fakeWorkspaceRepoName(info)
}
w.calls = append(w.calls, entry)
if w.sharedLog != nil {
*w.sharedLog = append(*w.sharedLog, entry)
}
return w.stashRef, w.stashErr
if w.stashErr != nil || w.stashRef == "" || info.RepoPath == "" {
return w.stashRef, w.stashErr
}
return w.stashRef + "/" + fakeWorkspaceRepoName(info), nil
}
func (w *fakeWorkspace) ApplyPreserved(_ context.Context, info ports.WorkspaceInfo, ref string) error {
w.calls = append(w.calls, "ApplyPreserved:"+string(info.SessionID))
entry := "ApplyPreserved:" + string(info.SessionID)
if info.RepoPath != "" {
entry = "ApplyPreserved:" + fakeWorkspaceRepoName(info) + ":" + ref
}
w.calls = append(w.calls, entry)
return w.applyErr
}
func fakeWorkspaceRepoName(info ports.WorkspaceInfo) string {
if filepath.Base(info.Path) == string(info.SessionID) {
return domain.RootWorkspaceRepoName
}
return filepath.Base(info.Path)
}
type fakeMessenger struct{ msgs []string }
func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
@ -842,7 +838,7 @@ func TestKill_WorkspaceProjectDestroysChildrenBeforeRoot(t *testing.T) {
if rt.destroyed != 1 {
t.Fatalf("runtime destroy calls = %d, want 1", rt.destroyed)
}
want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"}
want := []string{"Destroy:api", "Destroy:__root__"}
if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("destroy order = %v, want %v", got, want)
}
@ -868,7 +864,7 @@ func TestKill_WorkspaceProjectSkipsUnregisteredChildRows(t *testing.T) {
if err != nil || !freed {
t.Fatalf("freed=%v err=%v", freed, err)
}
want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"}
want := []string{"Destroy:api", "Destroy:__root__"}
if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("destroy calls = %v, want %v", got, want)
}
@ -898,7 +894,7 @@ func TestKill_WorkspaceProjectDirtyRowRefusesRemoval(t *testing.T) {
if freed {
t.Fatal("freed = true, want false for preserved workspace project")
}
want := []string{"DestroyWorkspaceProjectWorktree:api"}
want := []string{"Destroy:api"}
if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("calls = %v, want %v", got, want)
}
@ -1021,7 +1017,7 @@ func TestCleanup_WorkspaceProjectDestroysChildrenBeforeRoot(t *testing.T) {
if len(res.Cleaned) != 1 || res.Cleaned[0] != "mer-1" {
t.Fatalf("cleaned = %v, want mer-1", res.Cleaned)
}
want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"}
want := []string{"Destroy:api", "Destroy:__root__"}
if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("destroy order = %v, want %v", got, want)
}
@ -1956,7 +1952,7 @@ func TestSaveAndTeardownAll_WorkspaceProjectPreservesEachRepoAndRemovesChildrenF
if refs[domain.RootWorkspaceRepoName] != "refs/ao/preserved/mer-1/__root__" || refs["api"] != "refs/ao/preserved/mer-1/api" {
t.Fatalf("preserved refs = %v", refs)
}
wantSuffix := []string{"ForceDestroyWorkspaceProjectWorktree:api", "ForceDestroyWorkspaceProjectWorktree:__root__"}
wantSuffix := []string{"ForceDestroy:api", "ForceDestroy:__root__"}
gotSuffix := ws.calls[len(ws.calls)-2:]
if strings.Join(gotSuffix, ",") != strings.Join(wantSuffix, ",") {
t.Fatalf("force destroy suffix = %v, want %v; all calls %v", gotSuffix, wantSuffix, ws.calls)
@ -2238,13 +2234,13 @@ func TestRestoreAll_WorkspaceProjectRestoresAndAppliesEachRepo(t *testing.T) {
if err := m.RestoreAll(ctx); err != nil {
t.Fatalf("RestoreAll err = %v", err)
}
wantPrefix := []string{"RestoreWorkspaceProjectWorktree:__root__", "RestoreWorkspaceProjectWorktree:api"}
wantPrefix := []string{"Restore:__root__", "Restore:api"}
if got := ws.calls[:2]; strings.Join(got, ",") != strings.Join(wantPrefix, ",") {
t.Fatalf("restore prefix = %v, want %v; all calls %v", got, wantPrefix, ws.calls)
}
applied := strings.Join(ws.calls, ",")
if !strings.Contains(applied, "ApplyWorkspaceProjectPreserved:__root__:refs/ao/preserved/mer-1") ||
!strings.Contains(applied, "ApplyWorkspaceProjectPreserved:api:refs/ao/preserved/mer-1") {
if !strings.Contains(applied, "ApplyPreserved:__root__:refs/ao/preserved/mer-1") ||
!strings.Contains(applied, "ApplyPreserved:api:refs/ao/preserved/mer-1") {
t.Fatalf("apply calls missing, got %v", ws.calls)
}
if rt.created != 1 {