diff --git a/CLAUDE.md b/CLAUDE.md index d97764e..3a24fa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ make clean # remove local binary Run a single package's tests: ```bash go test ./internal/config/... -go test ./internal/session/... +go test ./internal/herd/... # etc. ``` @@ -66,9 +66,8 @@ All commands run locally — they execute git/tmux/filesystem directly on whatev - **`cmd/errors.go`** — error-printer helpers for consistent CLI error formatting - **`cmd/tui.go`** — TUI launch machinery (extracted from root) - **`internal/config`** — TOML config at `~/.config/codeherd/config.toml`; `Load()` returns empty `Config` on missing file; holds `Defaults{ProjectsDir, Agent}`, `Projects`, `Agents`; `RepoPath()` derives filesystem paths from git URLs (e.g. `git@github.com:user/myapp.git` → `github.com/user/myapp`); `AgentByName()` / `AgentNames()` for named agent lookup -- **`internal/session`** — tmux session lifecycle: start, stop, list, attach, show; `StartRequest.Type` and `SessionInfo.Type` unify agent and shell handling; `Service.Show`/`Stop` address by `(project, branch, sessionType)`; `SessionExistsError` carries `Project/Branch/Type`; `SetStatus` is agent-only; session state via tmux user-defined options -- **`internal/worktree`** — git worktree operations: new, delete, list, shell, env -- **`internal/project`** — project clone and directory management +- **`internal/herd`** — the domain. Owns projects, worktrees, and sessions together, because they are one thing: a workspace. `Herd` holds `cfg` + the active profile + the exec-boundary runners; `Ref` is identity (always the *identity* branch, never the display branch) and always carries the profile. Obtain a `Ref` from `h.Ref(project, branch)` or `Workspace.Ref` — never build one by hand. Operations: `EnsureWorkspace`, `Launch`, `List`, `Resolve`, `StopSessions`, `Teardown`, `Clone`, `Provision`, `SetStatus`. One error vocabulary lives here. +- **`internal/git`** — mechanism; `WorktreeRunner` + `CloneRunner` + `Runner` union, `RealRunner`, porcelain parsers. Never sees `cfg` or the profile. - **`internal/tmux`** — typed tmux command wrapper (`NewClient`, `Runner` interface for testing) - **`internal/tui`** — Bubble Tea v2 dashboard with session/worktree/project views - **`internal/herdtemplate`** — processes `.herd` template files with Go `text/template`; custom funcs: `port "name"` (deterministic FNV-1a hash), `env "VAR" "default"`; renders any `*.herd` file to its unsuffixed counterpart @@ -85,9 +84,11 @@ All commands run locally — they execute git/tmux/filesystem directly on whatev - **Struct-per-command**: each CLI command is a struct with a `Cobra()` method; flags are exported fields on the struct; verb groupers are wired in `cmd/register.go` - **Named agents**: `[agents.]` in config define cmd/args/env; selected via `--agent` flag or TUI picker; `AgentByName()` for lookup -- **Session types**: agent (default) vs shell; both are first-class in `internal/session` via `StartRequest.Type` and `SessionInfo.Type`; `Show`/`Stop`/`delete session` accept `--shell` to target the shell type +- **Session types**: agent (default) vs shell; both are first-class in `internal/herd` via the `SessionType` on `LaunchOpts` and `Handle`; `Resolve`/`StopSessions`/`delete session` accept `--shell` to target the shell type - **Session state in tmux**: session metadata stored as tmux user-defined options on sessions, not in state files -- **Mocking via interfaces**: `internal/tmux` exposes `Runner`; `internal/worktree` exposes `WorktreeRunner` — tests use mock implementations +- **Mocking via interfaces**: `internal/tmux` exposes `Runner` and `internal/git` exposes `Runner` (a `WorktreeRunner` + `CloneRunner` union) — tests fake at those two exec-boundary seams +- **Domain vs mechanism**: needs `cfg`, the profile, or identity to decide something → `internal/herd`. Does not → a support package. This is why `filecopy` and `herdtemplate` stayed out: they never needed the profile, which is why they were never implicated in the profile bugs. +- **Never build a `herd.Ref` by hand**: `h.Ref(project, branch)` supplies the profile; a literal `herd.Ref{Project: p, Branch: b}` is silently addressing the no-profile world. This is the convention that replaced `semconv.SessionName("", …)`, which failed nine times. - **Missing file = empty defaults**: `config.Load()` returns an empty `Config` (not an error) when the file doesn't exist - **`syscall.Exec` for interactive commands**: `attach session`, `create worktree --attach`, and related commands replace the process rather than spawning a child - **Local execution**: all session/project/worktree commands run git/tmux via `os/exec` on the local machine — no SSH indirection diff --git a/cmd/completion.go b/cmd/completion.go index c504b60..be7e15c 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -6,11 +6,23 @@ import ( "github.com/spf13/cobra" "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/herd" "github.com/xico42/codeherd/internal/tmux" - "github.com/xico42/codeherd/internal/worktree" ) +// ensureCompletionHerd builds the h global from the completion config when it +// is nil. PersistentPreRunE does not run before completion functions (the same +// reason loadCompletionConfig exists), so h is otherwise unset here. +func ensureCompletionHerd(cmd *cobra.Command) { + if h == nil { + h = herd.New(loadCompletionConfig(cmd), nil, herd.Deps{ + Tmux: tmux.NewRealRunner(), + Git: git.NewRealRunner(), + }) + } +} + // loadCompletionConfig loads config during a shell-completion call. // PersistentPreRunE does not run before completion functions, so the cfg // global is nil here. It reads the --config and --profile flag values off @@ -63,12 +75,10 @@ func completeProfiles(cmd *cobra.Command, _ []string, _ string) ([]string, cobra return completionProfileNames(cmd), cobra.ShellCompDirectiveNoFileComp } -// completionBranchLister lists worktree entries for a project during -// completion. Declared as a var so tests can stub it without touching git -// or tmux. -var completionBranchLister = func(cfg *config.Config, project string) ([]worktree.ListEntry, error) { - svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) - return svc.List(project) +// completionBranchLister lists workspaces for a project during completion. +// Declared as a var so tests can stub it without touching git or tmux. +var completionBranchLister = func(project string) ([]herd.Workspace, error) { + return h.List(project) } // completeBranches completes against the worktree branches of the project @@ -78,27 +88,29 @@ func completeBranches(cmd *cobra.Command, args []string, _ string) ([]string, co if len(args) == 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - entries, err := completionBranchLister(loadCompletionConfig(cmd), args[0]) + ensureCompletionHerd(cmd) + spaces, err := completionBranchLister(args[0]) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - return branchNames(entries), cobra.ShellCompDirectiveNoFileComp + return branchNames(spaces), cobra.ShellCompDirectiveNoFileComp } -// branchNames returns the sorted, deduplicated, non-empty branch names -// from worktree entries. -func branchNames(entries []worktree.ListEntry) []string { - seen := make(map[string]struct{}, len(entries)) +// branchNames returns the sorted, deduplicated, non-empty identity branch +// names from a project's workspaces. Identity — not the display branch — is +// what a user completing a command should type. +func branchNames(spaces []herd.Workspace) []string { + seen := make(map[string]struct{}, len(spaces)) var names []string - for _, e := range entries { - if e.Branch == "" { + for _, ws := range spaces { + if ws.Ref.Branch == "" { continue } - if _, dup := seen[e.Branch]; dup { + if _, dup := seen[ws.Ref.Branch]; dup { continue } - seen[e.Branch] = struct{}{} - names = append(names, e.Branch) + seen[ws.Ref.Branch] = struct{}{} + names = append(names, ws.Ref.Branch) } sort.Strings(names) return names @@ -106,9 +118,8 @@ func branchNames(entries []worktree.ListEntry) []string { // completionRemoteBrancher lists a project's remote-tracking branches during // completion (no fetch). Declared as a var so tests can stub it. -var completionRemoteBrancher = func(cfg *config.Config, project string) ([]worktree.RemoteBranch, error) { - svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) - return svc.ListRemoteBranches(project) +var completionRemoteBrancher = func(project string) ([]herd.RemoteBranch, error) { + return h.RemoteBranches(project, false) // no fetch — completion must stay fast } // completeRemoteBranches completes the --track flag against the remote-tracking @@ -117,7 +128,8 @@ func completeRemoteBranches(cmd *cobra.Command, args []string, _ string) ([]stri if len(args) == 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - branches, err := completionRemoteBrancher(loadCompletionConfig(cmd), args[0]) + ensureCompletionHerd(cmd) + branches, err := completionRemoteBrancher(args[0]) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/completion_internal_test.go b/cmd/completion_internal_test.go index f4c5343..d61103a 100644 --- a/cmd/completion_internal_test.go +++ b/cmd/completion_internal_test.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/worktree" + "github.com/xico42/codeherd/internal/herd" ) // withStubConfig swaps loadCompletionConfig for the duration of a test. @@ -22,8 +22,8 @@ func withStubConfig(t *testing.T, c *config.Config) { func TestCompleteRemoteBranches(t *testing.T) { orig := completionRemoteBrancher t.Cleanup(func() { completionRemoteBrancher = orig }) - completionRemoteBrancher = func(_ *config.Config, project string) ([]worktree.RemoteBranch, error) { - return []worktree.RemoteBranch{ + completionRemoteBrancher = func(project string) ([]herd.RemoteBranch, error) { + return []herd.RemoteBranch{ {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}, {Remote: "upstream", Branch: "fix-y", Ref: "upstream/fix-y"}, }, nil @@ -96,13 +96,13 @@ func TestCompleteProjects_sorted(t *testing.T) { } func TestBranchNames_dedupAndSkipEmpty(t *testing.T) { - entries := []worktree.ListEntry{ - {Project: "p", Branch: "main"}, - {Project: "p", Branch: ""}, - {Project: "p", Branch: "feature"}, - {Project: "p", Branch: "main"}, + spaces := []herd.Workspace{ + {Ref: herd.Ref{Project: "p", Branch: "main"}}, + {Ref: herd.Ref{Project: "p", Branch: ""}}, + {Ref: herd.Ref{Project: "p", Branch: "feature"}}, + {Ref: herd.Ref{Project: "p", Branch: "main"}}, } - got := branchNames(entries) + got := branchNames(spaces) want := []string{"feature", "main"} if !reflect.DeepEqual(got, want) { t.Fatalf("branchNames = %v, want %v", got, want) @@ -122,9 +122,12 @@ func TestCompleteBranches_needsProject(t *testing.T) { func TestCompleteBranches_listsForProject(t *testing.T) { orig := completionBranchLister var gotProject string - completionBranchLister = func(_ *config.Config, project string) ([]worktree.ListEntry, error) { + completionBranchLister = func(project string) ([]herd.Workspace, error) { gotProject = project - return []worktree.ListEntry{{Branch: "main"}, {Branch: "dev"}}, nil + return []herd.Workspace{ + {Ref: herd.Ref{Branch: "main"}}, + {Ref: herd.Ref{Branch: "dev"}}, + }, nil } t.Cleanup(func() { completionBranchLister = orig }) withStubConfig(t, &config.Config{}) @@ -144,7 +147,7 @@ func TestCompleteBranches_listsForProject(t *testing.T) { func TestCompleteBranches_listerErrorYieldsNothing(t *testing.T) { orig := completionBranchLister - completionBranchLister = func(*config.Config, string) ([]worktree.ListEntry, error) { + completionBranchLister = func(string) ([]herd.Workspace, error) { return nil, errors.New("boom") } t.Cleanup(func() { completionBranchLister = orig }) @@ -162,8 +165,8 @@ func TestCompleteBranches_listerErrorYieldsNothing(t *testing.T) { func TestCompleteProjectThenBranch_dispatch(t *testing.T) { withStubConfig(t, &config.Config{Projects: map[string]config.ProjectConfig{"alpha": {}}}) orig := completionBranchLister - completionBranchLister = func(*config.Config, string) ([]worktree.ListEntry, error) { - return []worktree.ListEntry{{Branch: "main"}}, nil + completionBranchLister = func(string) ([]herd.Workspace, error) { + return []herd.Workspace{{Ref: herd.Ref{Branch: "main"}}}, nil } t.Cleanup(func() { completionBranchLister = orig }) diff --git a/cmd/errors.go b/cmd/errors.go index 30a9f87..a6dcd89 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -3,51 +3,43 @@ package cmd import ( "errors" "fmt" - "os" - "github.com/spf13/cobra" - - "github.com/xico42/codeherd/internal/session" - "github.com/xico42/codeherd/internal/worktree" + "github.com/xico42/codeherd/internal/herd" ) -func worktreeErr(cmd *cobra.Command, project, branch string, err error) error { - switch { - case errors.Is(err, worktree.ErrNotCloned): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s is not cloned. Run 'ch clone project %s' first.\n", project, project) - case errors.Is(err, worktree.ErrWorktreeExists): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: worktree %s/%s already exists.\n", project, branch) - case errors.Is(err, worktree.ErrWorktreeNotFound): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: worktree %s/%s not found. Run 'ch create worktree %s %s' first.\n", project, branch, project, branch) - case errors.Is(err, worktree.ErrSessionRunning): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: session %s-%s is running. Stop it first or use --force.\n", project, branch) - default: - return err - } - os.Exit(1) - return nil -} - -func sessionErr(cmd *cobra.Command, err error) error { +// herdErr is the CLI's single error translator. Every command funnels its +// domain errors through here, matching herd sentinels and nothing else +// (the one error vocabulary — herd owns the sentinels, the front end owns +// the presentation). +// +// It RETURNS the error rather than printing and calling os.Exit. Execute +// prints it once (prefixed "Error: ") and main exits non-zero. The previous +// shape printed and called os.Exit(1) inside RunE, which made the trailing +// `return nil` unreachable and bypassed Execute's error path. +// +// project and branch supply context for the friendly messages; pass the +// identity values in scope at the call site (or ws.Ref.Project / +// ws.Ref.Branch). For ErrSessionExists the context is read from the typed +// *herd.SessionExistsError instead, so the two positional args are ignored +// on that branch. +func herdErr(project, branch string, err error) error { switch { - case errors.Is(err, session.ErrSessionExists): - var sesErr *session.SessionExistsError + case errors.Is(err, herd.ErrNotCloned): + return fmt.Errorf("%s is not cloned. Run 'ch clone project %s' first", project, project) + case errors.Is(err, herd.ErrWorktreeExists): + return fmt.Errorf("worktree %s/%s already exists", project, branch) + case errors.Is(err, herd.ErrWorktreeNotFound): + return fmt.Errorf("worktree %s/%s not found. Run 'ch create worktree %s %s' first", project, branch, project, branch) + case errors.Is(err, herd.ErrSessionRunning): + return fmt.Errorf("session %s-%s is running. Stop it first or use --force", project, branch) + case errors.Is(err, herd.ErrSessionExists): + var sesErr *herd.SessionExistsError if errors.As(err, &sesErr) { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: session %s/%s (%s) already exists. Attach with 'ch attach session %s %s'.\n", sesErr.Project, sesErr.Branch, sesErr.Type, sesErr.Project, sesErr.Branch) - } else { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err) + return fmt.Errorf("session %s/%s (%s) already exists. Attach with 'ch attach session %s %s'", + sesErr.Ref.Project, sesErr.Ref.Branch, sesErr.Type, sesErr.Ref.Project, sesErr.Ref.Branch) } - case errors.Is(err, session.ErrSessionNotFound): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err) - case errors.Is(err, session.ErrPathNotFound): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err) - case errors.Is(err, worktree.ErrNotCloned): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err) - case errors.Is(err, worktree.ErrWorktreeNotFound): - fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err) + return err default: return err } - os.Exit(1) - return nil } diff --git a/cmd/errors_internal_test.go b/cmd/errors_internal_test.go new file mode 100644 index 0000000..a70f3b4 --- /dev/null +++ b/cmd/errors_internal_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/xico42/codeherd/internal/herd" +) + +func TestHerdErr_notCloned(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrNotCloned) + want := "myapp is not cloned. Run 'ch clone project myapp' first" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_worktreeExists(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrWorktreeExists) + want := "worktree myapp/feat already exists" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_worktreeNotFound(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrWorktreeNotFound) + want := "worktree myapp/feat not found. Run 'ch create worktree myapp feat' first" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_sessionRunning(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrSessionRunning) + want := "session myapp-feat is running. Stop it first or use --force" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_sessionExists_carriesRefFromTypedError(t *testing.T) { + se := &herd.SessionExistsError{ + Ref: herd.Ref{Project: "myapp", Branch: "feat"}, + Type: herd.SessionTypeAgent, + } + got := herdErr("ignored", "ignored", se) + want := "session myapp/feat (agent) already exists. Attach with 'ch attach session myapp feat'" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_unknownSentinel_passesThrough(t *testing.T) { + raw := errors.New("boom") + got := herdErr("myapp", "feat", raw) + if got != raw { + t.Fatalf("herdErr() = %v, want the original error %v", got, raw) + } +} diff --git a/cmd/plugin.go b/cmd/plugin.go index bfcb605..27f2c47 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -6,11 +6,9 @@ import ( "github.com/spf13/cobra" - "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/herd" "github.com/xico42/codeherd/internal/notify" "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" - "github.com/xico42/codeherd/internal/tmux" ) const maxAnnotationLen = 120 @@ -44,22 +42,20 @@ var pluginHandleClaudeCmd = &cobra.Command{ return nil // fail-open } - tc := tmux.NewClient(tmux.NewRealRunner()) - sesSvc := session.NewService(tc, &hooks.NoOp{}) notifySvc := notify.NewDefaultService() switch input.HookEventName { case "UserPromptSubmit": - _ = sesSvc.SetStatus(sessionName, semconv.StatusRunning, "") + _ = h.SetStatus(sessionName, herd.StatusRunning, "") case "Notification": annotation := truncate(input.Message, maxAnnotationLen) - _ = sesSvc.SetStatus(sessionName, semconv.StatusWaiting, annotation) + _ = h.SetStatus(sessionName, herd.StatusWaiting, annotation) _ = notifySvc.Send("codeherd", annotation) case "Stop": annotation := truncate(input.LastAssistantMessage, maxAnnotationLen) - _ = sesSvc.SetStatus(sessionName, semconv.StatusWaiting, annotation) + _ = h.SetStatus(sessionName, herd.StatusWaiting, annotation) _ = notifySvc.Send("codeherd", annotation) } diff --git a/cmd/project.go b/cmd/project.go index 4c05a76..649c985 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -3,13 +3,11 @@ package cmd import ( "errors" "fmt" - "sort" "text/tabwriter" "github.com/spf13/cobra" - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/project" + "github.com/xico42/codeherd/internal/herd" ) // ── ListProjectCmd ─────────────────────────────────────────────────── @@ -27,8 +25,7 @@ func (c *ListProjectCmd) Cobra() *cobra.Command { } func (c *ListProjectCmd) Run(cmd *cobra.Command, args []string) error { - svc := newProjectService() - entries := svc.List() + entries := h.Projects() w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) fmt.Fprintln(w, "NAME\tREPO\tBRANCH") for _, e := range entries { @@ -56,8 +53,7 @@ func (c *ShowProjectCmd) Cobra() *cobra.Command { } func (c *ShowProjectCmd) Run(cmd *cobra.Command, args []string) error { - svc := newProjectService() - e, err := svc.Show(args[0]) + e, err := h.Project(args[0]) if err != nil { return fmt.Errorf("show project: %w", err) } @@ -97,22 +93,15 @@ func (c *CloneProjectCmd) Cobra() *cobra.Command { func (c *CloneProjectCmd) Run(cmd *cobra.Command, args []string) error { if c.All { - names := make([]string, 0, len(cfg.Projects)) - for name := range cfg.Projects { - names = append(names, name) - } - sort.Strings(names) hadFailure := false - for _, name := range names { - projCfg := cfg.Projects[name] - h := hooks.New(projCfg.Hooks) - svc := project.NewService(cfg, project.NewRealGitRunner(), h) - err := svc.Clone(name) + for _, p := range h.Projects() { + name := p.Name + err := h.Clone(name) switch { case err == nil: fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s... done\n", name) default: - var ace *project.AlreadyClonedError + var ace *herd.AlreadyClonedError if errors.As(err, &ace) { fmt.Fprintf(cmd.OutOrStdout(), "Warning: %s\n", ace) } else { @@ -131,20 +120,17 @@ func (c *CloneProjectCmd) Run(cmd *cobra.Command, args []string) error { return fmt.Errorf("requires a project name, or use --all") } name := args[0] - projCfg := cfg.Projects[name] - h := hooks.New(projCfg.Hooks) - svc := project.NewService(cfg, project.NewRealGitRunner(), h) fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s... ", name) - err := svc.Clone(name) + err := h.Clone(name) switch { case err == nil: fmt.Fprintln(cmd.OutOrStdout(), "done") - if e, showErr := svc.Show(name); showErr == nil { + if e, showErr := h.Project(name); showErr == nil { fmt.Fprintf(cmd.OutOrStdout(), " Path: %s\n", e.Path) } default: fmt.Fprintln(cmd.OutOrStdout()) - var ace *project.AlreadyClonedError + var ace *herd.AlreadyClonedError if errors.As(err, &ace) { fmt.Fprintf(cmd.OutOrStdout(), "Warning: %s\n", ace) } else { diff --git a/cmd/root.go b/cmd/root.go index 649ab59..ce6bf28 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,10 @@ import ( "github.com/spf13/pflag" "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/herd" "github.com/xico42/codeherd/internal/semconv" + "github.com/xico42/codeherd/internal/tmux" ) var ( @@ -17,6 +20,9 @@ var ( profileFlag string cfg *config.Config registry *config.ProfileRegistry + // h is the domain. It is the only service any command constructs, and + // it is constructed exactly once, here. + h *herd.Herd ) var rootCmd = &cobra.Command{ @@ -39,6 +45,10 @@ It is like a shepherd, but for coding agents :). if err != nil { return fmt.Errorf("loading config: %w", err) } + h = herd.New(cfg, registry, herd.Deps{ + Tmux: tmux.NewRealRunner(), + Git: git.NewRealRunner(), + }) return nil }, } @@ -72,7 +82,7 @@ func Execute(version string) error { resetAllFlags(rootCmd) rootCmd.Version = version if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, "Error:", err) return fmt.Errorf("%w", err) } return nil diff --git a/cmd/root_internal_test.go b/cmd/root_internal_test.go index 7d718d6..f9a5279 100644 --- a/cmd/root_internal_test.go +++ b/cmd/root_internal_test.go @@ -1,11 +1,23 @@ package cmd import ( + "os" "testing" + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/herd" "github.com/xico42/codeherd/internal/semconv" ) +// TestMain seeds a default Herd for the internal package-cmd tests that invoke +// a command's Run directly, bypassing PersistentPreRunE (which constructs h in +// production). Tests that need a specific config or tmux runner override h via +// setHerdTmux (cmd/session_internal_test.go). +func TestMain(m *testing.M) { + h = herd.New(&config.Config{}, nil, herd.Deps{}) + os.Exit(m.Run()) +} + func TestResolveProfileArg(t *testing.T) { tests := []struct { name string diff --git a/cmd/services.go b/cmd/services.go deleted file mode 100644 index c1c1036..0000000 --- a/cmd/services.go +++ /dev/null @@ -1,100 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/project" - "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" - "github.com/xico42/codeherd/internal/tmux" - "github.com/xico42/codeherd/internal/worktree" -) - -// newWorktreeService returns a *worktree.Service for read-only paths -// (list, show). Write paths (create, delete) construct their own service -// inline because they need hooks bound to the project's config. -func newWorktreeService() *worktree.Service { - return worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) -} - -// newSessionService returns a *session.Service for read-only paths -// (list, show). Create/delete paths construct their own service inline -// with a project-bound hook. Declared as a var for test overriding. -var newSessionService = func() *session.Service { - return session.NewService(tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{}) -} - -// newProjectService returns a *project.Service for read-only paths -// (list, show). Clone constructs its own service inline with a -// project-bound hook. -func newProjectService() *project.Service { - return project.NewService(cfg, project.NewRealGitRunner(), &hooks.NoOp{}) -} - -// activeProfile returns the currently active profile name, or "" when -// profile mode is off. Safe when registry is nil. -func activeProfile() string { - if registry == nil { - return "" - } - return registry.Active -} - -// showSessionForProfile dispatches Show/ShowByName based on whether a -// profile is active. Callers pass the logical (project, branch, type). -// ByName paths look up by canonical name (profile-qualified), which is -// always SessionName regardless of type — ShellSessionName differs only -// for the tmux display name. Errors are wrapped with %w so callers can -// still match against session sentinels via errors.Is. -func showSessionForProfile(svc *session.Service, project, branch, sessionType string) (*session.SessionInfo, error) { - prof := activeProfile() - if prof == "" { - info, err := svc.Show(project, branch, sessionType) - if err != nil { - return nil, fmt.Errorf("showing session: %w", err) - } - return info, nil - } - info, err := svc.ShowByName(semconv.SessionName(prof, project, branch), sessionType) - if err != nil { - return nil, fmt.Errorf("showing session: %w", err) - } - return info, nil -} - -// stopSessionForProfile dispatches Stop/StopByName based on the active -// profile. -func stopSessionForProfile(svc *session.Service, project, branch, sessionType string) error { - prof := activeProfile() - if prof == "" { - if err := svc.Stop(project, branch, sessionType); err != nil { - return fmt.Errorf("stopping session: %w", err) - } - return nil - } - if err := svc.StopByName(semconv.SessionName(prof, project, branch), sessionType); err != nil { - return fmt.Errorf("stopping session: %w", err) - } - return nil -} - -// listSessionsForProfile returns only sessions matching the active -// profile. With no active profile, all sessions are returned. -func listSessionsForProfile(svc *session.Service) ([]session.SessionInfo, error) { - all, err := svc.List() - if err != nil { - return nil, fmt.Errorf("listing sessions: %w", err) - } - prof := activeProfile() - if prof == "" { - return all, nil - } - var out []session.SessionInfo - for _, s := range all { - if s.Profile == prof { - out = append(out, s) - } - } - return out, nil -} diff --git a/cmd/services_test.go b/cmd/services_test.go deleted file mode 100644 index ce88696..0000000 --- a/cmd/services_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package cmd - -import ( - "errors" - "strings" - "testing" - - "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" - "github.com/xico42/codeherd/internal/tmux" -) - -// fakeRunner is a minimal tmux.Runner for helper tests. It returns a -// canned list-sessions stdout for the first call and empty-ok for any -// follow-up (e.g. kill-session). -type fakeRunner struct { - listStdout string - calls [][]string - idx int -} - -func (f *fakeRunner) Run(args ...string) (string, string, int, error) { - f.calls = append(f.calls, args) - f.idx++ - if f.idx == 1 { - // First call is list-sessions. - if f.listStdout == "" { - return "", "", 1, nil // exit 1 = no sessions - } - return f.listStdout, "", 0, nil - } - return "", "", 0, nil -} - -func setRegistry(t *testing.T, r *config.ProfileRegistry) { - t.Helper() - orig := registry - registry = r - t.Cleanup(func() { registry = orig }) -} - -func newHelperSessionService(r tmux.Runner) *session.Service { - return session.NewService(tmux.NewClient(r), &hooks.NoOp{}) -} - -// tabRecord builds a list-sessions line matching the 8-field tab format -// defined in internal/tmux/client.go ListSessions: id, name, canonical, -// type, status, annotation, started_at, profile. -func tabRecord(id, name, canonical, sessionType, status, profile string) string { - return strings.Join([]string{id, name, canonical, sessionType, status, "", "", profile}, "\t") + "\n" -} - -func callMatches(calls [][]string, want ...string) bool { - for _, c := range calls { - joined := strings.Join(c, " ") - ok := true - for _, w := range want { - if !strings.Contains(joined, w) { - ok = false - break - } - } - if ok { - return true - } - } - return false -} - -func TestActiveProfile_NilRegistry(t *testing.T) { - setRegistry(t, nil) - if got := activeProfile(); got != "" { - t.Errorf("activeProfile() = %q, want empty string", got) - } -} - -func TestActiveProfile_PopulatedRegistry(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - if got := activeProfile(); got != "work" { - t.Errorf("activeProfile() = %q, want %q", got, "work") - } -} - -func TestActiveProfile_EmptyActive(t *testing.T) { - // Registry present but Active == "" (profile mode off in-practice). - setRegistry(t, &config.ProfileRegistry{Active: ""}) - if got := activeProfile(); got != "" { - t.Errorf("activeProfile() = %q, want empty", got) - } -} - -func TestListSessionsForProfile_NoProfile_ReturnsAll(t *testing.T) { - setRegistry(t, nil) - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") + - tabRecord("$2", "work-myapp-feat", "work-myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "work") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - got, err := listSessionsForProfile(svc) - if err != nil { - t.Fatalf("listSessionsForProfile() error = %v", err) - } - if len(got) != 2 { - t.Errorf("len(sessions) = %d, want 2 (all records returned when profile is off)", len(got)) - } -} - -func TestListSessionsForProfile_ActiveProfile_FiltersMatchingOnly(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") + - tabRecord("$2", "work-myapp-feat", "work-myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "work") + - tabRecord("$3", "personal-myapp-feat", "personal-myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "personal") + - tabRecord("$4", "work-other-main", "work-other-main", semconv.SessionTypeShell, semconv.StatusRunning, "work") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - got, err := listSessionsForProfile(svc) - if err != nil { - t.Fatalf("listSessionsForProfile() error = %v", err) - } - if len(got) != 2 { - t.Fatalf("len(sessions) = %d, want 2 (only records with Profile==work)", len(got)) - } - for _, s := range got { - if s.Profile != "work" { - t.Errorf("returned session %q has Profile=%q, want work", s.Name, s.Profile) - } - } -} - -func TestListSessionsForProfile_NoMatches_ReturnsEmpty(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - stdout := tabRecord("$1", "personal-myapp-feat", "personal-myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "personal") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - got, err := listSessionsForProfile(svc) - if err != nil { - t.Fatalf("listSessionsForProfile() error = %v", err) - } - if len(got) != 0 { - t.Errorf("len(sessions) = %d, want 0", len(got)) - } -} - -func TestShowSessionForProfile_NoProfile_HitsPlainShow(t *testing.T) { - setRegistry(t, nil) - // Plain Show looks up by canonical "myapp-feat" (profile == ""). - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - info, err := showSessionForProfile(svc, "myapp", "feat", semconv.SessionTypeAgent) - if err != nil { - t.Fatalf("showSessionForProfile() error = %v", err) - } - if info == nil || info.Name != "myapp-feat" { - t.Errorf("info = %+v, want Name=myapp-feat", info) - } - if info.Profile != "" { - t.Errorf("info.Profile = %q, want empty", info.Profile) - } -} - -func TestShowSessionForProfile_ActiveProfile_HitsShowByName(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - // Also include a non-profile record with the same short name to - // prove the helper targets the profile-prefixed canonical key. - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") + - tabRecord("$2", "work-myapp-feat", "work-myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "work") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - info, err := showSessionForProfile(svc, "myapp", "feat", semconv.SessionTypeAgent) - if err != nil { - t.Fatalf("showSessionForProfile() error = %v", err) - } - if info == nil || info.Name != "work-myapp-feat" { - t.Errorf("info.Name = %+v, want work-myapp-feat (profile-prefixed canonical)", info) - } - if info.Profile != "work" { - t.Errorf("info.Profile = %q, want work", info.Profile) - } -} - -func TestShowSessionForProfile_ActiveProfile_NotFound(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - // Only a non-profile record exists — ShowByName for "work-myapp-feat" must miss. - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - _, err := showSessionForProfile(svc, "myapp", "feat", semconv.SessionTypeAgent) - if err == nil { - t.Fatal("expected ErrSessionNotFound, got nil") - } - if !errors.Is(err, session.ErrSessionNotFound) { - t.Errorf("err = %v, want errors.Is(err, ErrSessionNotFound) == true (%%w wrap must preserve sentinel)", err) - } -} - -func TestStopSessionForProfile_NoProfile_TargetsPlainName(t *testing.T) { - setRegistry(t, nil) - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - if err := stopSessionForProfile(svc, "myapp", "feat", semconv.SessionTypeAgent); err != nil { - t.Fatalf("stopSessionForProfile() error = %v", err) - } - if !callMatches(r.calls, "kill-session", "-t", "myapp-feat") { - t.Errorf("expected kill-session -t myapp-feat; calls = %v", r.calls) - } -} - -func TestStopSessionForProfile_ActiveProfile_TargetsProfilePrefixedName(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - // Two records sharing the short name; only the profile-prefixed one should be killed. - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") + - tabRecord("$2", "work-myapp-feat", "work-myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "work") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - if err := stopSessionForProfile(svc, "myapp", "feat", semconv.SessionTypeAgent); err != nil { - t.Fatalf("stopSessionForProfile() error = %v", err) - } - if !callMatches(r.calls, "kill-session", "-t", "work-myapp-feat") { - t.Errorf("expected kill-session -t work-myapp-feat; calls = %v", r.calls) - } - // Make sure we did NOT kill the non-profile session. - for _, c := range r.calls { - joined := strings.Join(c, " ") - if strings.Contains(joined, "kill-session") && strings.Contains(joined, "-t myapp-feat") && !strings.Contains(joined, "work-myapp-feat") { - t.Errorf("kill-session targeted non-profile session: %v", c) - } - } -} - -func TestStopSessionForProfile_ActiveProfile_NotFound(t *testing.T) { - setRegistry(t, &config.ProfileRegistry{Active: "work"}) - stdout := tabRecord("$1", "myapp-feat", "myapp-feat", semconv.SessionTypeAgent, semconv.StatusRunning, "") - r := &fakeRunner{listStdout: stdout} - svc := newHelperSessionService(r) - - err := stopSessionForProfile(svc, "myapp", "feat", semconv.SessionTypeAgent) - if err == nil { - t.Fatal("expected ErrSessionNotFound, got nil") - } - if !errors.Is(err, session.ErrSessionNotFound) { - t.Errorf("err = %v, want errors.Is(err, ErrSessionNotFound) == true", err) - } -} diff --git a/cmd/session.go b/cmd/session.go index 309b681..71b045c 100644 --- a/cmd/session.go +++ b/cmd/session.go @@ -6,34 +6,17 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "syscall" "text/tabwriter" "time" "github.com/spf13/cobra" - "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/filecopy" - "github.com/xico42/codeherd/internal/herdtemplate" - "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/herd" "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" "github.com/xico42/codeherd/internal/tmux" - "github.com/xico42/codeherd/internal/worktree" ) -// resolveAgentName returns the agent name from the flag or config default. -func resolveAgentName(flagValue string) (string, error) { - if flagValue != "" { - return flagValue, nil - } - if cfg.Defaults.Agent != "" { - return cfg.Defaults.Agent, nil - } - return "", fmt.Errorf("no agent specified; use --agent or set defaults.agent in config") -} - // execTmuxAttach attaches to a tmux session. If already inside tmux, uses // switch-client. Otherwise, replaces the process with tmux attach-session. var execTmuxAttach = func(name string) error { @@ -74,15 +57,14 @@ func (c *ListSessionCmd) Cobra() *cobra.Command { } func (c *ListSessionCmd) Run(cmd *cobra.Command, _ []string) error { - svc := newSessionService() - sessions, err := listSessionsForProfile(svc) + sessions, err := h.Sessions() if err != nil { - return err + return fmt.Errorf("listing sessions: %w", err) } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) fmt.Fprintln(w, "SESSION\tTYPE\tSTATUS") for _, s := range sessions { - fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, s.Type, s.Status) + fmt.Fprintf(w, "%s\t%s\t%s\n", s.Ref.CanonicalName(), s.Type, s.Status) } if err := w.Flush(); err != nil { return fmt.Errorf("flushing output: %w", err) @@ -111,14 +93,12 @@ func (c *ShowSessionCmd) Cobra() *cobra.Command { func (c *ShowSessionCmd) Run(cmd *cobra.Command, args []string) error { project, branch := args[0], args[1] - sessionType := sessionTypeFromFlag(c.Shell) - svc := newSessionService() - info, err := showSessionForProfile(svc, project, branch, sessionType) + info, err := h.Resolve(h.Ref(project, branch), sessionTypeFromFlag(c.Shell)) if err != nil { - return sessionErr(cmd, err) + return herdErr(project, branch, err) } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "Session:\t%s\n", info.Name) + fmt.Fprintf(w, "Session:\t%s\n", info.Ref.CanonicalName()) fmt.Fprintf(w, "Type:\t%s\n", info.Type) fmt.Fprintf(w, "Status:\t%s\n", info.Status) if info.Annotation != "" { @@ -162,113 +142,38 @@ func (c *CreateSessionCmd) Run(cmd *cobra.Command, args []string) error { sessionType := sessionTypeFromFlag(c.Shell) - var sessionCmd string - var sessionEnv map[string]string - - if c.Shell { - sessionCmd = os.Getenv("SHELL") - if sessionCmd == "" { - sessionCmd = "/bin/sh" - } - sessionEnv = nil - } else { - flagAgent := "" - if cmd.Flags().Changed("agent") { - flagAgent = c.Agent - } - agentName, err := resolveAgentName(flagAgent) - if err != nil { - return err - } - agent, err := cfg.AgentByName(agentName) - if err != nil { - return fmt.Errorf("resolving agent: %w", err) - } - sessionCmd = agent.Command() - sessionEnv = agent.Env + flagAgent := "" + if cmd.Flags().Changed("agent") { + flagAgent = c.Agent } - projCfg := cfg.Projects[project] - h := hooks.New(projCfg.Hooks) - - wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), h) - path, err := wtSvc.WorktreePath(project, branch) - if err != nil { - if errors.Is(err, worktree.ErrWorktreeNotFound) { - fmt.Fprintf(cmd.OutOrStdout(), "Worktree %s/%s not found, creating... ", project, branch) - result, createErr := wtSvc.New(project, branch) - if createErr != nil { - fmt.Fprintln(cmd.OutOrStdout()) - return worktreeErr(cmd, project, branch, createErr) - } - fmt.Fprintln(cmd.OutOrStdout(), "done") - path = result.Path - - // File copy - if len(projCfg.Files) > 0 { - repoPath, _ := config.RepoPath(projCfg.Repo) - cloneDir := filepath.Join(cfg.Defaults.ProjectsDir, repoPath) - copySvc := filecopy.New(h) - attrs := map[string]string{ - semconv.HookAttrProject: project, - semconv.HookAttrBranch: branch, - semconv.HookAttrWorktreePath: result.Path, - } - if err := copySvc.Copy(projCfg.Files, cloneDir, result.Path, attrs); err != nil { - return fmt.Errorf("copying files: %w", err) - } - } - - // Template processing - tmplSvc := herdtemplate.New(h) - tmplAttrs := map[string]string{ - semconv.HookAttrProject: project, - semconv.HookAttrBranch: branch, - semconv.HookAttrWorktreePath: result.Path, - } - if _, err := tmplSvc.Process(herdtemplate.ProcessContext{ - Project: project, - Branch: branch, - WorktreePath: result.Path, - SessionName: semconv.SessionName(activeProfile(), project, branch), - }, tmplAttrs); err != nil { - return fmt.Errorf("processing templates: %w", err) - } - } else { - return sessionErr(cmd, err) + ref := h.Ref(project, branch) + // Ensure the worktree exists, tolerating the common case where it already + // does. One call replaces the old probe-then-create dance. + if _, err := h.EnsureWorkspace(ref, herd.EnsureOpts{Provision: true}); err != nil { + if !errors.Is(err, herd.ErrWorktreeExists) { + return herdErr(project, branch, err) } + // Already there — that is the common case for `create session`. + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Worktree %s/%s not found, creating... done\n", project, branch) } - profile := activeProfile() - name := semconv.SessionName(profile, project, branch) - if sessionType == semconv.SessionTypeShell { - name = semconv.ShellSessionName(profile, project, branch) + name := ref.CanonicalName() + if sessionType == herd.SessionTypeShell { + name = semconv.ShellSessionName(ref.Profile, project, branch) } fmt.Fprintf(cmd.OutOrStdout(), "Starting session %s... ", name) - var cloneDir string - if repoPath, rpErr := config.RepoPath(projCfg.Repo); rpErr == nil { - cloneDir = filepath.Join(cfg.Defaults.ProjectsDir, repoPath) - } - - tc := tmux.NewClient(tmux.NewRealRunner()) - svc := session.NewService(tc, h) - sessionID, err := svc.Start(session.StartRequest{ - Project: project, - Branch: branch, - Path: path, - CloneDir: cloneDir, - Type: sessionType, - Cmd: sessionCmd, - Env: sessionEnv, - Profile: profile, - Attach: c.Attach, + handle, err := h.Launch(ref, herd.LaunchOpts{ + Type: sessionType, + Agent: flagAgent, // "" means defaults.agent — resolved inside Launch + Attach: c.Attach, }) if err != nil { fmt.Fprintln(cmd.OutOrStdout()) - return sessionErr(cmd, err) + return herdErr(project, branch, err) } - fmt.Fprintln(cmd.OutOrStdout(), "done") if !c.Attach { shellSuffix := "" @@ -276,12 +181,9 @@ func (c *CreateSessionCmd) Run(cmd *cobra.Command, args []string) error { shellSuffix = " --shell" } fmt.Fprintf(cmd.OutOrStdout(), "Attach with: ch attach session %s %s%s\n", project, branch, shellSuffix) + return nil } - - if c.Attach { - return execTmuxAttach(sessionID) - } - return nil + return execTmuxAttach(handle.ID) } // ── delete ─────────────────────────────────────────────────────────────────── @@ -308,14 +210,13 @@ func (c *DeleteSessionCmd) Cobra() *cobra.Command { func (c *DeleteSessionCmd) Run(cmd *cobra.Command, args []string) error { project, branch := args[0], args[1] sessionType := sessionTypeFromFlag(c.Shell) - svc := newSessionService() if !c.Force { - info, err := showSessionForProfile(svc, project, branch, sessionType) + info, err := h.Resolve(h.Ref(project, branch), sessionType) if err != nil { - return sessionErr(cmd, err) + return herdErr(project, branch, err) } - if info.Status == semconv.StatusRunning { + if info.Status == herd.StatusRunning { fmt.Fprintf(cmd.OutOrStdout(), "Delete session %s/%s (%s)? [y/N] ", project, branch, sessionType) scanner := bufio.NewScanner(cmd.InOrStdin()) scanner.Scan() @@ -327,9 +228,9 @@ func (c *DeleteSessionCmd) Run(cmd *cobra.Command, args []string) error { } fmt.Fprintf(cmd.OutOrStdout(), "Stopping %s/%s... ", project, branch) - if err := stopSessionForProfile(svc, project, branch, sessionType); err != nil { + if _, err := h.StopSessions(h.Ref(project, branch), herd.StopOpts{Type: sessionType}); err != nil { fmt.Fprintln(cmd.OutOrStdout()) - return sessionErr(cmd, err) + return herdErr(project, branch, err) } fmt.Fprintln(cmd.OutOrStdout(), "done") return nil @@ -356,21 +257,19 @@ func (c *AttachSessionCmd) Cobra() *cobra.Command { func (c *AttachSessionCmd) Run(cmd *cobra.Command, args []string) error { project, branch := args[0], args[1] - sessionType := sessionTypeFromFlag(c.Shell) - svc := newSessionService() - info, err := showSessionForProfile(svc, project, branch, sessionType) + info, err := h.Resolve(h.Ref(project, branch), sessionTypeFromFlag(c.Shell)) if err != nil { - return sessionErr(cmd, err) + return herdErr(project, branch, err) } - return execTmuxAttach(info.SessionID) + return execTmuxAttach(info.ID) } // ── helpers ────────────────────────────────────────────────────────────────── -// sessionTypeFromFlag maps the --shell flag to a session type constant. -func sessionTypeFromFlag(shell bool) string { +// sessionTypeFromFlag maps the --shell flag to a herd session type. +func sessionTypeFromFlag(shell bool) herd.SessionType { if shell { - return semconv.SessionTypeShell + return herd.SessionTypeShell } - return semconv.SessionTypeAgent + return herd.SessionTypeAgent } diff --git a/cmd/session_internal_test.go b/cmd/session_internal_test.go index 28af8d7..a453fa8 100644 --- a/cmd/session_internal_test.go +++ b/cmd/session_internal_test.go @@ -10,9 +10,8 @@ import ( "testing" "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/herd" "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" "github.com/xico42/codeherd/internal/tmux" ) @@ -24,76 +23,37 @@ func setTestConfig(t *testing.T, c *config.Config) { t.Cleanup(func() { cfg = orig }) } -func TestResolveAgentName_flagTakesPrecedence(t *testing.T) { - setTestConfig(t, &config.Config{ - Defaults: config.DefaultsConfig{Agent: "default-agent"}, - Agents: map[string]config.AgentConfig{ - "default-agent": {Cmd: "default"}, - "flag-agent": {Cmd: "flag"}, - }, - }) - name, err := resolveAgentName("flag-agent") - if err != nil { - t.Fatal(err) - } - if name != "flag-agent" { - t.Errorf("resolveAgentName = %q, want flag-agent", name) - } -} - -func TestResolveAgentName_fallsBackToDefault(t *testing.T) { - setTestConfig(t, &config.Config{ - Defaults: config.DefaultsConfig{Agent: "my-default"}, - Agents: map[string]config.AgentConfig{ - "my-default": {Cmd: "claude"}, - }, - }) - name, err := resolveAgentName("") - if err != nil { - t.Fatal(err) - } - if name != "my-default" { - t.Errorf("resolveAgentName = %q, want my-default", name) - } +// setHerdTmux points the package-level Herd at the given tmux runner and keeps +// the package cfg in sync (production builds both from one config load). +// Session commands go through h; there is no service seam to override any more. +func setHerdTmux(t *testing.T, c *config.Config, r tmux.Runner) { + t.Helper() + setTestConfig(t, c) + orig := h + h = herd.New(c, registry, herd.Deps{Tmux: r}) + t.Cleanup(func() { h = orig }) } -func TestResolveAgentName_errorWhenNoneSet(t *testing.T) { - setTestConfig(t, &config.Config{}) - _, err := resolveAgentName("") - if err == nil { - t.Error("resolveAgentName should error when no agent specified and no default") - } +// sessionLine builds a tmux list-sessions record in the 10-field tab format +// ListSessions parses: id, name, canonical, type, status, annotation, +// started_at, profile, branch, project. The branch and project fields are what +// let h.Resolve rebuild a matching Ref. +func sessionLine(id, project, branch, stype, status string) string { + canonical := semconv.SessionName("", project, branch) + return strings.Join([]string{ + id, canonical, canonical, stype, status, "", "", "", branch, project, + }, "\t") + "\n" } func TestSessionTypeFromFlag(t *testing.T) { - if got := sessionTypeFromFlag(true); got != semconv.SessionTypeShell { - t.Errorf("sessionTypeFromFlag(true) = %q, want %q", got, semconv.SessionTypeShell) + if got := sessionTypeFromFlag(true); got != herd.SessionTypeShell { + t.Errorf("sessionTypeFromFlag(true) = %q, want %q", got, herd.SessionTypeShell) } - if got := sessionTypeFromFlag(false); got != semconv.SessionTypeAgent { - t.Errorf("sessionTypeFromFlag(false) = %q, want %q", got, semconv.SessionTypeAgent) + if got := sessionTypeFromFlag(false); got != herd.SessionTypeAgent { + t.Errorf("sessionTypeFromFlag(false) = %q, want %q", got, herd.SessionTypeAgent) } } -// listSessionsResponse formats a fake tmux list-sessions output that the session -// service can parse. Fields: session_id, name, canonical_name, session_type, -// status, annotation, started_at. -func listSessionsResponse(id, name, canonical, stype, status string) string { - return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t\t\n", id, name, canonical, stype, status) -} - -// newTestSessionService creates a session.Service backed by the given mock runner. -func newTestSessionService(r tmux.Runner) *session.Service { - return session.NewService(tmux.NewClient(r), &hooks.NoOp{}) -} - -// overrideSessionService replaces newSessionService for the duration of the test. -func overrideSessionService(t *testing.T, svc *session.Service) { - t.Helper() - orig := newSessionService - newSessionService = func() *session.Service { return svc } - t.Cleanup(func() { newSessionService = orig }) -} - // failWriter is an io.Writer that always returns an error, used to test // flush-error paths in tabwriter. type failWriter struct{} @@ -107,11 +67,10 @@ func (failWriter) Write([]byte) (int, error) { func TestListSession_flushError(t *testing.T) { r := &multiMockRunner{ responses: []mockResponse{ - {stdout: listSessionsResponse("$1", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, + {stdout: sessionLine("$1", "myapp", "feat", "agent", "running"), exitCode: 0}, }, } - svc := newTestSessionService(r) - overrideSessionService(t, svc) + setHerdTmux(t, &config.Config{}, r) c := &ListSessionCmd{} cobraCmd := c.Cobra() @@ -128,11 +87,10 @@ func TestListSession_flushError(t *testing.T) { func TestShowSession_flushError(t *testing.T) { r := &multiMockRunner{ responses: []mockResponse{ - {stdout: listSessionsResponse("$1", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, + {stdout: sessionLine("$1", "myapp", "feat", "agent", "running"), exitCode: 0}, }, } - svc := newTestSessionService(r) - overrideSessionService(t, svc) + setHerdTmux(t, &config.Config{}, r) c := &ShowSessionCmd{} cobraCmd := c.Cobra() @@ -149,12 +107,10 @@ func TestShowSession_flushError(t *testing.T) { func TestShowSession_outputsSessionInfo(t *testing.T) { r := &multiMockRunner{ responses: []mockResponse{ - // list-sessions response for svc.Show - {stdout: listSessionsResponse("$42", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, + {stdout: sessionLine("$42", "myapp", "feat", "agent", "running"), exitCode: 0}, }, } - svc := newTestSessionService(r) - overrideSessionService(t, svc) + setHerdTmux(t, &config.Config{}, r) c := &ShowSessionCmd{} cobraCmd := c.Cobra() @@ -183,12 +139,10 @@ func TestDeleteSession_promptAbort(t *testing.T) { // Return a running session so the prompt fires. r := &multiMockRunner{ responses: []mockResponse{ - // list-sessions for svc.Show (inside !Force branch) - {stdout: listSessionsResponse("$10", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, + {stdout: sessionLine("$10", "myapp", "feat", "agent", "running"), exitCode: 0}, }, } - svc := newTestSessionService(r) - overrideSessionService(t, svc) + setHerdTmux(t, &config.Config{}, r) c := &DeleteSessionCmd{} cobraCmd := c.Cobra() @@ -213,23 +167,21 @@ func TestDeleteSession_promptAbort(t *testing.T) { } } -// TestDeleteSession_promptConfirm_stop verifies that DeleteSessionCmd.Run -// proceeds to svc.Stop when the user confirms with "y". It expects Stop to -// return ErrSessionNotFound (because list-sessions is called again in Stop), -// so we supply a second list-sessions response. +// TestDeleteSession_promptConfirm_callsStop verifies that DeleteSessionCmd.Run +// proceeds to StopSessions when the user confirms with "y", killing the session +// by its stable tmux ID. func TestDeleteSession_promptConfirm_callsStop(t *testing.T) { r := &multiMockRunner{ responses: []mockResponse{ - // list-sessions for svc.Show - {stdout: listSessionsResponse("$10", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, - // list-sessions for svc.Stop - {stdout: listSessionsResponse("$10", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, + // list-sessions for h.Resolve (confirmation probe) + {stdout: sessionLine("$10", "myapp", "feat", "agent", "running"), exitCode: 0}, + // list-sessions for h.StopSessions + {stdout: sessionLine("$10", "myapp", "feat", "agent", "running"), exitCode: 0}, // kill-session {stdout: "", exitCode: 0}, }, } - svc := newTestSessionService(r) - overrideSessionService(t, svc) + setHerdTmux(t, &config.Config{}, r) c := &DeleteSessionCmd{} cobraCmd := c.Cobra() @@ -252,29 +204,27 @@ func TestDeleteSession_promptConfirm_callsStop(t *testing.T) { t.Errorf("output %q does not contain done", out.String()) } - // Verify kill-session was called. - var killCalled bool + // Verify kill-session was called by stable ID ($10). + var killedByID bool for _, call := range r.calls { - if call[0] == "kill-session" { - killCalled = true + if call[0] == "kill-session" && call[len(call)-1] == "$10" { + killedByID = true } } - if !killCalled { - t.Error("expected kill-session call, not found") + if !killedByID { + t.Error("expected kill-session -t $10 (by stable ID), not found") } } // TestAttachSession_callsExecTmuxAttach verifies that AttachSessionCmd.Run -// calls execTmuxAttach with the session ID from Show. +// calls execTmuxAttach with the session ID from Resolve. func TestAttachSession_callsExecTmuxAttach(t *testing.T) { r := &multiMockRunner{ responses: []mockResponse{ - // list-sessions for svc.Show — returns session with ID $42 - {stdout: listSessionsResponse("$42", "myapp-feat", "myapp-feat", "agent", "running"), exitCode: 0}, + {stdout: sessionLine("$42", "myapp", "feat", "agent", "running"), exitCode: 0}, }, } - svc := newTestSessionService(r) - overrideSessionService(t, svc) + setHerdTmux(t, &config.Config{}, r) var attachedTo string origExec := execTmuxAttach @@ -298,10 +248,11 @@ func TestAttachSession_callsExecTmuxAttach(t *testing.T) { } // TestCreateSession_autoCreate_worktreeNotFound verifies that CreateSessionCmd -// prints "Worktree ... not found, creating..." and calls wtSvc.New when the -// worktree directory does not exist. The real git runner will fail (not a -// git repo), which produces a non-sentinel error that flows through -// worktreeErr's default branch and is returned (no os.Exit). +// tries to create the worktree via EnsureWorkspace when it does not exist. The +// real git runner will fail (not a git repo), which produces a non-sentinel +// error that flows through worktreeErr's default branch and is returned (no +// os.Exit). The "not found, creating... done" banner now prints only on the +// successful create path, so this failure case surfaces the git error instead. func TestCreateSession_autoCreate_worktreeNotFound(t *testing.T) { // cloneDir exists (project is "cloned") but worktreePath does not. projectsDir := t.TempDir() @@ -326,26 +277,33 @@ func TestCreateSession_autoCreate_worktreeNotFound(t *testing.T) { err := c.Run(cobraCmd, []string{"myapp", "feat"}) // Expect an error from git (not a real git repo), not from os.Exit. - // The error should NOT be nil since the worktree creation fails. if err == nil { t.Fatal("expected error from git worktree creation, got nil") } - - // The "creating..." message should appear in output. - if !strings.Contains(out.String(), "not found, creating") { - t.Errorf("output %q does not contain 'not found, creating'", out.String()) - } } -// TestCreateSession_agentPath_missingWorktree exercises the non-shell branch -// to verify agent resolution errors are surfaced cleanly. +// TestCreateSession_agent_noDefaultAgent exercises the agent branch to verify +// agent-resolution errors surface. With the worktree already present, the +// command reaches h.Launch, whose agent resolution fails when neither --agent +// nor defaults.agent is set. func TestCreateSession_agent_noDefaultAgent(t *testing.T) { - setTestConfig(t, &config.Config{ - Defaults: config.DefaultsConfig{}, + projectsDir := t.TempDir() + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + worktreePath := filepath.Join(cloneDir+"__worktrees", "feat") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(worktreePath, 0o755); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir}, // no default agent Projects: map[string]config.ProjectConfig{ "myapp": {Repo: "git@github.com:user/myapp.git"}, }, - }) + } + setHerdTmux(t, cfg, &multiMockRunner{}) // empty runner: list-sessions returns no sessions c := &CreateSessionCmd{} // Shell=false, no agent set cobraCmd := c.Cobra() @@ -362,17 +320,14 @@ func TestCreateSession_agent_noDefaultAgent(t *testing.T) { } // TestCreateSession_shell_existingWorktree verifies the shell path proceeds -// past the worktree check and reaches the session start phase. The real tmux -// runner is used; we expect a tmux-level error (session creation) rather than -// a worktree error. +// past the worktree check and reaches the session start phase against a real +// (isolated) tmux server. func TestCreateSession_shell_existingWorktree(t *testing.T) { if _, err := exec.LookPath("tmux"); err != nil { t.Skip("tmux not available") } // Isolate tmux to a per-test socket so the test's transient session does - // not appear on the developer's outer tmux server. See useIsolatedTmux - // for the full rationale; we inline a smaller version here because that - // helper lives in package cmd_test and this test is package cmd. + // not appear on the developer's outer tmux server. socket := filepath.Join(t.TempDir(), "tmux.sock") t.Setenv(tmux.SocketEnvVar, socket) t.Setenv("TMUX", "") @@ -393,12 +348,13 @@ func TestCreateSession_shell_existingWorktree(t *testing.T) { t.Fatal(err) } - setTestConfig(t, &config.Config{ + cfg := &config.Config{ Defaults: config.DefaultsConfig{ProjectsDir: projectsDir}, Projects: map[string]config.ProjectConfig{ "myapp": {Repo: "git@github.com:user/myapp.git"}, }, - }) + } + setHerdTmux(t, cfg, tmux.NewRealRunner()) origExec := execTmuxAttach t.Cleanup(func() { execTmuxAttach = origExec }) @@ -411,12 +367,7 @@ func TestCreateSession_shell_existingWorktree(t *testing.T) { var out bytes.Buffer cobraCmd.SetOut(&out) - // The tmux call may succeed (creates a real session) or fail with a - // non-sentinel error. Either way, we should reach the "Starting session..." - // print, confirming the worktree and agent-resolution paths were traversed. runErr := c.Run(cobraCmd, []string{"myapp", "feat"}) - // The per-test tmux server tear-down in t.Cleanup will remove any - // session created above; no explicit kill-session needed. if !strings.Contains(out.String(), "Starting session myapp-feat~sh") { t.Errorf("output %q does not show shell session name (runErr=%v)", out.String(), runErr) diff --git a/cmd/session_test.go b/cmd/session_test.go index 358805f..d848c32 100644 --- a/cmd/session_test.go +++ b/cmd/session_test.go @@ -92,11 +92,12 @@ func TestCreateSession_unconfiguredProject(t *testing.T) { } } -func TestCreateSession_noAgentConfigured_errors(t *testing.T) { +func TestCreateSession_worktreeCreationFails_returnsError(t *testing.T) { cfgDir := t.TempDir() + projectsDir := t.TempDir() cfgPath := filepath.Join(cfgDir, "config.toml") content := `[defaults] -projects_dir = "` + t.TempDir() + `" +projects_dir = "` + projectsDir + `" [projects.myapp] repo = "git@github.com:user/myapp.git" @@ -104,12 +105,18 @@ repo = "git@github.com:user/myapp.git" if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { t.Fatal(err) } + // Create the clone dir so EnsureWorkspace gets past the not-cloned check + // (which now goes through worktreeErr's os.Exit, like create worktree). + // The clone dir is not a real git repo, so worktree creation fails with a + // non-sentinel error that flows through worktreeErr's default branch and is + // returned (no os.Exit) — the returnable path this test can observe. + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } err := runCmd(t, "--config", cfgPath, "create", "session", "myapp", "main") if err == nil { - t.Fatal("expected error when no agent configured") - } - if !strings.Contains(err.Error(), "no agent specified") { - t.Errorf("error = %q, want to contain 'no agent specified'", err.Error()) + t.Fatal("expected error for an uncreatable session") } } diff --git a/cmd/template.go b/cmd/template.go index a432d60..dc94f15 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -69,18 +69,18 @@ func (c *TemplateCmd) Run(cmd *cobra.Command, args []string) error { } projCfg := cfg.Projects[project] - h := hooks.New(projCfg.Hooks) + hook := hooks.New(projCfg.Hooks) if !c.DryRun { fmt.Fprintf(cmd.OutOrStdout(), "Processing templates... ") } - tmplSvc := herdtemplate.New(h) + tmplSvc := herdtemplate.New(hook) result, err := tmplSvc.Process(herdtemplate.ProcessContext{ Project: project, Branch: branch, WorktreePath: absDir, - SessionName: semconv.SessionName("", project, branch), + SessionName: h.Ref(project, branch).CanonicalName(), DryRun: c.DryRun, }, map[string]string{ semconv.HookAttrProject: project, diff --git a/cmd/tui.go b/cmd/tui.go index afb4f70..ef6fb61 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -7,12 +7,9 @@ import ( tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/project" "github.com/xico42/codeherd/internal/semconv" "github.com/xico42/codeherd/internal/tmux" "github.com/xico42/codeherd/internal/tui" - "github.com/xico42/codeherd/internal/worktree" ) func runTUI(cmd *cobra.Command) error { @@ -117,12 +114,8 @@ func respawnIfDead(tmuxClient *tmux.Client, sessionName string) error { } func runTUIDirect(tmuxClient *tmux.Client) error { - wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, &hooks.NoOp{}) - sesSvc := newSessionService() - projSvc := project.NewService(cfg, project.NewRealGitRunner(), &hooks.NoOp{}) - insideTmux := os.Getenv("TMUX") != "" - m := tui.NewModel(cfg, wtSvc, sesSvc, projSvc, tmuxClient, insideTmux, registry) + m := tui.NewModel(h, tmuxClient, insideTmux) p := tea.NewProgram(m) finalModel, err := p.Run() diff --git a/cmd/worktree.go b/cmd/worktree.go index c9f47fa..c9e17fd 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -3,19 +3,11 @@ package cmd import ( "bufio" "fmt" - "path/filepath" "text/tabwriter" "github.com/spf13/cobra" - "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/filecopy" - "github.com/xico42/codeherd/internal/herdtemplate" - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" - "github.com/xico42/codeherd/internal/tmux" - "github.com/xico42/codeherd/internal/worktree" + "github.com/xico42/codeherd/internal/herd" ) // ── list ───────────────────────────────────────────────────────────────────── @@ -38,23 +30,22 @@ func (c *ListWorktreeCmd) Run(cmd *cobra.Command, args []string) error { if len(args) == 1 { project = args[0] } - svc := newWorktreeService() - entries, err := svc.List(project) + spaces, err := h.List(project) if err != nil { return fmt.Errorf("list: %w", err) } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) fmt.Fprintln(w, "PROJECT\tBRANCH\tPATH\tSESSION") - for _, e := range entries { - sess := e.Session - if sess == "" { - sess = "--" + for _, ws := range spaces { + sess := "--" + if ws.Agent != nil { + sess = ws.Agent.Ref.CanonicalName() + " (running)" } - branch := e.Branch + branch := ws.DisplayBranch if branch == "" { branch = "(detached)" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Project, branch, e.Path, sess) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ws.Ref.Project, branch, ws.Path, sess) } if err := w.Flush(); err != nil { return fmt.Errorf("flushing output: %w", err) @@ -101,101 +92,41 @@ func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error { return fmt.Errorf("a argument is required unless --track is given") } - projCfg := cfg.Projects[project] - h := hooks.New(projCfg.Hooks) - - var cloneDir string - if repoPath, rpErr := config.RepoPath(projCfg.Repo); rpErr == nil { - cloneDir = filepath.Join(cfg.Defaults.ProjectsDir, repoPath) - } - - svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), h) - var result worktree.NewResult - var err error switch { case c.Track != "": fmt.Fprintf(cmd.OutOrStdout(), "Checking out %s into a new worktree... ", c.Track) - result, err = svc.NewTracking(project, posBranch, c.Track) - case c.From != "": - fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch) - result, err = svc.NewFrom(project, posBranch, c.From) default: fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch) - result, err = svc.New(project, posBranch) } + + ws, err := h.EnsureWorkspace(h.Ref(project, posBranch), herd.EnsureOpts{ + AutoClone: false, // the CLI never auto-clones — previously implicit, now stated + Provision: true, + StartPoint: c.From, + Track: c.Track, + }) if err != nil { fmt.Fprintln(cmd.OutOrStdout()) - return worktreeErr(cmd, project, posBranch, err) - } - - branch := result.Branch - - // File copy - if len(projCfg.Files) > 0 { - copySvc := filecopy.New(h) - attrs := map[string]string{ - semconv.HookAttrProject: project, - semconv.HookAttrBranch: branch, - semconv.HookAttrWorktreePath: result.Path, - } - if err := copySvc.Copy(projCfg.Files, cloneDir, result.Path, attrs); err != nil { - return fmt.Errorf("copying files: %w", err) - } - } - - // Template processing - tmplSvc := herdtemplate.New(h) - tmplAttrs := map[string]string{ - semconv.HookAttrProject: project, - semconv.HookAttrBranch: branch, - semconv.HookAttrWorktreePath: result.Path, - } - if _, err := tmplSvc.Process(herdtemplate.ProcessContext{ - Project: project, - Branch: branch, - WorktreePath: result.Path, - SessionName: semconv.SessionName("", project, branch), - }, tmplAttrs); err != nil { - return fmt.Errorf("processing templates: %w", err) + return herdErr(project, posBranch, err) } fmt.Fprintln(cmd.OutOrStdout(), "done") - fmt.Fprintf(cmd.OutOrStdout(), " Path: %s\n", result.Path) + fmt.Fprintf(cmd.OutOrStdout(), " Path: %s\n", ws.Path) if c.Attach { flagAgent := "" if cmd.Flags().Changed("agent") { flagAgent = c.Agent } - agentName, err := resolveAgentName(flagAgent) - if err != nil { - return err - } - agent, err := cfg.AgentByName(agentName) - if err != nil { - return fmt.Errorf("resolving agent: %w", err) - } - - name := semconv.SessionName("", project, branch) - fmt.Fprintf(cmd.OutOrStdout(), "Starting session %s... ", name) - - sesSvc := session.NewService(tmux.NewClient(tmux.NewRealRunner()), h) - sessionID, err := sesSvc.Start(session.StartRequest{ - Project: project, - Branch: branch, - Path: result.Path, - CloneDir: cloneDir, - Cmd: agent.Command(), - Env: agent.Env, - Attach: true, - }) + // ws.Ref is authoritative — Track may have derived a different local branch. + fmt.Fprintf(cmd.OutOrStdout(), "Starting session %s... ", ws.Ref.CanonicalName()) + handle, err := h.Launch(ws.Ref, herd.LaunchOpts{Agent: flagAgent, Attach: true}) if err != nil { fmt.Fprintln(cmd.OutOrStdout()) - return fmt.Errorf("starting session: %w", err) + return herdErr(ws.Ref.Project, ws.Ref.Branch, err) } - fmt.Fprintln(cmd.OutOrStdout(), "done") - return execTmuxAttach(sessionID) + return execTmuxAttach(handle.ID) } return nil @@ -234,15 +165,9 @@ func (c *DeleteWorktreeCmd) Run(cmd *cobra.Command, args []string) error { } fmt.Fprintf(cmd.OutOrStdout(), "Deleting worktree %s/%s... ", project, branch) - svc := newWorktreeService() - err := svc.Delete(worktree.DeleteRequest{ - Project: project, - Branch: branch, - Force: c.Force, - }) - if err != nil { + if err := h.Teardown(h.Ref(project, branch), herd.TeardownOpts{Force: c.Force}); err != nil { fmt.Fprintln(cmd.OutOrStdout()) - return worktreeErr(cmd, project, branch, err) + return herdErr(project, branch, err) } fmt.Fprintln(cmd.OutOrStdout(), "done") diff --git a/docs/superpowers/plans/2026-07-15-herd-collapse.md b/docs/superpowers/plans/2026-07-15-herd-collapse.md new file mode 100644 index 0000000..7a9ced7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-herd-collapse.md @@ -0,0 +1,3041 @@ +# Plan 1 — the collapse + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Spec:** `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md`. Read §2, §6, §12.1, and §14 before starting. This plan is Plan 1 of three; Plans 2 (front-end thinning) and 3 (coverage contract) are written later, in their own sessions, informed by what this one records in §14.1 of the spec. + +**Goal:** Collapse `internal/session`, `internal/worktree`, and `internal/project` into a single `internal/herd` domain package that owns identity (the active profile) and therefore cannot address a session it did not create. + +**Architecture:** One `Herd` value holds `cfg` + the active profile + the exec-boundary runners, and hands out `Ref` values that always carry the profile. Every session lookup is keyed on a `Ref`, so the profile-blind `semconv.SessionName("", …)` literal — the root cause of the shipped defect and three of its siblings (spec §3.1) — has nowhere left to live. Git exec moves to a new `internal/git` mechanism package. Build order is **project → session → worktree** (spec §12.1); each stage moves one domain's logic *and* migrates its callers, so `cmd/` and `internal/tui/` are touched once per domain rather than twice. + +**Tech Stack:** Go (module `github.com/xico42/codeherd`), Cobra, Bubble Tea v2, tmux, git. Tests are stdlib `testing` with hand-written fakes at the `Runner` seam. + +## Global Constraints + +- **`make check` must pass before every commit.** It runs coverage (80% floor), integration tests, lint, and build. A task is not done until it is green. +- **Coverage floor: 80% aggregate.** Deleting three packages that sit at 91% / 89.3% / high coverage while adding an uncovered `herd` will sink the total. Tests move *with* the code in the same commit, never in a follow-up. +- **`herd` must never import `cmd` or `internal/tui`.** Keeps a future promotion out of `internal/` a rename (spec §11). +- **Typed enums:** every closed-set string gets a defined Go type with named constants (`SessionType`, `Status`). No bare string parameters for these. +- **`wrapcheck` is enabled.** Every error crossing a package boundary must be wrapped with `%w` and a context prefix. `_test.go` files are exempt (`.golangci.yml`). +- **`goimports` with `local-prefixes: github.com/xico42/codeherd`.** Import blocks are stdlib / third-party / codeherd. +- **Test repos must pin the default branch:** `git init -b main `, never bare `git init` (CLAUDE.md). +- **Tests touching real tmux must isolate it:** set `CODEHERD_TMUX_SOCKET` under `t.TempDir()`, clear `$TMUX`, probe-and-skip, `kill-server` on cleanup. Never `exec.Command("tmux", …)` directly from a test. +- **A stage that rewrites tests instead of moving them is a signal the stage is doing too much** (spec §12.1). +- **Record the handoff as you go, not at the end.** Every task's commit step appends what it learned to spec §14.1, in that same commit. See below. + +## Recording the handoff (read this before Task 1) + +Spec §14 is the handoff between sessions, and it is load-bearing: Plans 2 and 3 are written in fresh sessions whose only context is the spec. If this plan is executed subagent-per-task, **the agent running Task 6 never saw Tasks 1–5** — it cannot reconstruct what surprised you, what cost an hour, or which assumption turned out wrong. Nobody can write that down after the fact. + +So each task writes its own notes, in its own commit: + +- Every task's commit step ends with an append to spec §14.1 and `git add`s the spec alongside the code. +- **Append; do not rewrite.** §14 says so explicitly. Later tasks add below earlier ones. +- Write it under a `**Task N — **` sub-heading so Task 6 can curate it into prose. +- **If a task learned nothing worth recording, write nothing.** An empty §14.1 after Task 1 is a fine outcome; padding it with "went as planned" is worse than silence, because it dilutes the signal Plan 2 is reading for. + +What is worth recording (spec §14's own list): + +| | | +|---|---| +| **Assumptions this document got wrong** | Name the section, so the next session distrusts the right paragraph | +| **API changes** | The real signature, if it moved off this plan's Deviations table | +| **Decisions reversed** | Which §11 row, and what forced it | +| **Traps** | Anything costing more than ~30 minutes — especially cross-layer surprises: tmux behaviour, git worktree edge cases, Cobra lifecycle, test isolation | +| **Deferred work** | What you skipped, and which plan should pick it up | +| **Behaviour changes** | Anything a user could notice. This refactor ships four that are known going in; there may be more | + +## Deviations from the spec's §6 sketch + +The spec says the §6 surface "is a sketch, not a contract" and asks for the real one to be recorded. These are decided here, up front, and must be copied into spec §14.1 by Task 6: + +| Spec §6 says | This plan does | Why | +|---|---|---| +| `New(cfg *config.Config, profile string, deps Deps)` | `New(cfg *config.Config, registry *config.ProfileRegistry, deps Deps)` | `WithProfile(name)` needs `ProfilesDir` to call `config.LoadProfile`, and only the registry has it. The spec's own §8.1 sample (`herd.New(cfg, registry.Active, …)`) nil-panics when profiles are off — `config.Load` returns a nil registry, which is exactly why `cmd/services.go:37` guards it. Passing the registry makes `New` total and deletes `activeProfile()`. | +| `hooks` does not appear anywhere | Unexported field `newHook func(config.HooksConfig) hooks.Hook`, defaulted in `New` | Spec §3.2's constraint is that hooks must not be **bound at construction** — that is what created the dead `Model.sesSvc` fields. A defaulted, test-overridable field satisfies that and keeps the 8 existing hook tests moving intact (spec §10) instead of being rewritten against real shell commands. It stays out of the exported API. | +| `Handle` has a `Ref` | Same, plus `@codeherd_project` is stamped as a new tmux option | Without it, `Ref.Project` cannot be recovered from a tmux record (the canonical name is ambiguous — spec §11), so `Sessions()` would return `Handle`s with a half-populated `Ref`. A `Ref` missing only `Project` is a footgun that compiles into `Teardown`. One extra `SetOption` + one format field removes it. | +| — | `Project(name string) (Project, error)` added | `ch show project ` needs one project with `Cloned` status. §6 listed only `Projects()`. | +| — | `CloneAll` dropped, not moved | `project.Service.CloneAll` has **zero non-test callers** — `cmd/project.go:99-128` runs its own loop. YAGNI. | +| `RemoteBranch` declared in `herd` | Declared in `internal/git`, re-exported from `herd` as a type alias | `git.Runner.ListRemoteBranches` returns it, so declaring it in `herd` would force a conversion loop at the exec boundary. `type RemoteBranch = git.RemoteBranch` gives §6's surface for free. | +| Files: `session.go worktree.go project.go launch.go teardown.go list.go` | `herd.go errors.go paths.go project.go session.go workspace.go` | Launch/Teardown/List are each ~40 lines and belong beside the domain they operate on. Six files either way. | + +## File Structure + +**Created:** + +| File | Responsibility | +|---|---| +| `internal/git/git.go` | `WorktreeRunner` + `CloneRunner` + `Runner` union; `RealRunner`; `WorktreeInfo` / `RemoteBranch`; porcelain parsers. Mechanism — no `cfg`, no profile. | +| `internal/git/git_test.go` | Parser unit tests (moved from `worktree_test.go`). | +| `internal/git/realrunner_test.go` | Real-git integration-ish tests (moved from `internal/worktree/realrunner_test.go`). | +| `internal/herd/herd.go` | `Herd`, `Ref`, `Deps`, `New`, `WithProfile`, `SessionType`, `Status`, `hookFor`. | +| `internal/herd/errors.go` | Every sentinel + `AlreadyClonedError` + `SessionExistsError`. One vocabulary (spec §9). | +| `internal/herd/paths.go` | `cloneDir` / `worktreesRoot` / `worktreePath` / `projectNames`. Pure identity → config derivation. | +| `internal/herd/project.go` | `Project`, `Projects`, `Project(name)`, `Clone`. | +| `internal/herd/session.go` | `Handle`, `LaunchOpts`, `StopOpts`, `Launch`, `Resolve`, `Sessions`, `StopSessions`, `SetStatus`. | +| `internal/herd/workspace.go` | `Workspace`, `EnsureOpts`, `TeardownOpts`, `EnsureWorkspace`, `Provision`, `List`, `Teardown`, `RemoteBranches`. | +| `internal/herd/fakes_test.go` | The **one** shared fake set: `fakeGit` (satisfies the 14-method union), `fakeTmux`, `mockHook`. Per-test overrides via func fields. | + +**Deleted (by the end of Task 5):** `internal/project/`, `internal/session/`, `internal/worktree/` — all files. + +**Modified:** `cmd/root.go` (composition root), `cmd/services.go`, `cmd/project.go`, `cmd/session.go`, `cmd/worktree.go`, `cmd/template.go`, `cmd/completion.go`, `cmd/plugin.go`, `cmd/errors.go`, `cmd/tui.go`, `internal/tmux/client.go` (one new option), `internal/tui/model.go`, `actions.go`, `form.go`, `agent_picker.go`, `remote_picker.go`. + +**Test packages:** all `herd` tests live in `package herd` (internal), not `package herd_test`. Reason: `worktree_test.go` already tests unexported helpers (`parseRef`, `freshenStartPoint`, `resolvePaths`), and `project_test.go` + `session_test.go` **both** declare `mockHook` / `hookCall` — merging them into one external test package collides. One internal test package + one shared `fakes_test.go` resolves both. + +--- + +### Task 1: `internal/git` — rehouse the exec boundary + +Pure move. `internal/worktree` and `internal/project` keep compiling via type aliases, which Tasks 3 and 5 delete. + +**Files:** +- Create: `internal/git/git.go` +- Create: `internal/git/git_test.go` +- Create: `internal/git/realrunner_test.go` (moved from `internal/worktree/realrunner_test.go`) +- Modify: `internal/worktree/worktree.go:19-289` (delete the runner + parsers, add aliases) +- Modify: `internal/project/project.go:25-49` (delete the runner, add aliases) +- Delete: `internal/worktree/realrunner_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + package git + + type WorktreeInfo struct{ Path, Branch string; Detached bool } + type RemoteBranch struct{ Remote, Branch, Ref string } + + type WorktreeRunner interface { + Add(cloneDir, worktreePath, branch string) error + AddNewBranch(cloneDir, worktreePath, branch string) error + AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error + Remove(cloneDir, worktreePath string) error + List(cloneDir string) ([]WorktreeInfo, error) + Fetch(cloneDir, remote, branch string) error + FetchAll(cloneDir string) error + FastForward(cloneDir, remote, branch string) error + Remotes(cloneDir string) ([]string, error) + ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) + AddTracking(cloneDir, worktreePath, branch, remoteRef string) error + HasLocalBranch(cloneDir, branch string) (bool, error) + } + type CloneRunner interface{ Clone(repo, path, branch string) error } + type Runner interface{ WorktreeRunner; CloneRunner } + + type RealRunner struct{} + func NewRealRunner() *RealRunner // implements Runner + ``` + Unexported, used by Task 5: `parseRef(remotes []string, ref string) (remote, branch string, explicit bool)` — **exported here as `ParseRef`**, because Task 5's `freshenStartPoint` lives in `herd` and needs it. + +- [ ] **Step 1: Create `internal/git/git.go`** + +Move verbatim from `internal/worktree/worktree.go`, renaming the receiver type only: +- `WorktreeInfo` (lines 29-33), `RemoteBranch` (51-55) +- `WorktreeRunner` interface (58-72) — note the interface has **12** methods; the spec's "13 methods" in §5 miscounted. `Runner` is therefore a 13-method union, not 14. +- `RealWorktreeRunner` methods (80-221) → methods on `RealRunner` +- `parseWorktreePorcelain` (225-250), `parseRemoteBranches` (255-273) +- `parseRef` (279-289) → exported as `ParseRef`, same body, same doc comment + +Move verbatim from `internal/project/project.go`: +- `RealGitRunner.Clone` (37-49) → `func (r *RealRunner) Clone(repo, path, branch string) error`, same body + +Add at the top: +```go +// Package git wraps git command execution. It is a mechanism package: it +// never sees the config, the active profile, or a Ref — it only takes paths +// and refs it is handed. Exactly one real implementation exists; the +// interfaces exist so internal/herd can fake the exec boundary in tests. +package git + +// Runner is the union both herd and its tests depend on. Splitting it +// further is out of scope: it sits at the exec boundary where one real +// implementation exists. +type Runner interface { + WorktreeRunner + CloneRunner +} + +// NewRealRunner returns a Runner backed by the system git binary. +func NewRealRunner() *RealRunner { return &RealRunner{} } +``` + +- [ ] **Step 2: Move the parser tests** + +`git grep -n 'parseWorktreePorcelain\|parseRemoteBranches\|parseRef' internal/worktree/worktree_test.go` to find them. Move each matching `func Test…` into `internal/git/git_test.go` (`package git`), renaming `parseRef` → `ParseRef` at call sites. Delete them from `worktree_test.go`. + +Move `internal/worktree/realrunner_test.go` → `internal/git/realrunner_test.go`: change line 1 to `package git`, rename `NewRealWorktreeRunner()` → `NewRealRunner()` and `RealWorktreeRunner` → `RealRunner` throughout. Its `runGit` / `realRunnerRepos` helpers come along unchanged. + +Add one test for the newly-unioned `Clone` (there is no runner-level clone test today — `project_test.go` fakes it): + +```go +func TestRealRunner_Clone(t *testing.T) { + src := t.TempDir() + runGit(t, src, "init", "-b", "main", ".") + if err := os.WriteFile(filepath.Join(src, "f.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, src, "add", ".") + runGit(t, src, "commit", "-m", "init") + + dst := filepath.Join(t.TempDir(), "clone") + if err := NewRealRunner().Clone(src, dst, "main"); err != nil { + t.Fatalf("Clone: %v", err) + } + if _, err := os.Stat(filepath.Join(dst, "f.txt")); err != nil { + t.Errorf("cloned tree missing f.txt: %v", err) + } +} +``` + +- [ ] **Step 3: Alias the old packages so the tree stays green** + +In `internal/worktree/worktree.go`, delete lines 28-33 (`WorktreeInfo`), 51-55 (`RemoteBranch`), 57-221 (`WorktreeRunner` + `RealWorktreeRunner`), 223-289 (parsers), and the now-unused `bufio` / `os/exec` imports. Add: + +```go +// Deprecated: these aliases keep this package compiling while its logic +// moves to internal/herd. Deleted in the worktree stage of the collapse. +type WorktreeInfo = git.WorktreeInfo +type RemoteBranch = git.RemoteBranch +type WorktreeRunner = git.WorktreeRunner + +func NewRealWorktreeRunner() *git.RealRunner { return git.NewRealRunner() } +``` + +Every internal use of `parseRef` in `worktree.go` (`freshenStartPoint:327`, `NewTracking:478`) becomes `git.ParseRef`. + +In `internal/project/project.go`, delete lines 25-49 (`GitRunner`, `RealGitRunner`, `NewRealGitRunner`, `Clone`) and the `os/exec` import. Add: + +```go +// Deprecated: alias kept while this package's logic moves to internal/herd. +type GitRunner = git.CloneRunner + +func NewRealGitRunner() *git.RealRunner { return git.NewRealRunner() } +``` + +`worktree_test.go`'s `mockGit` and `project_test.go`'s `mockGitRunner` still satisfy the aliased interfaces unchanged — do not touch them. + +- [ ] **Step 4: Verify** + +Run: `make check` +Expected: `OK: % >= 80%`, integration green, lint clean, build clean. Coverage should be roughly flat — nothing was deleted, only relocated. + +If `wrapcheck` fires on the moved `RealRunner` methods, it is a false positive from the package move; the bodies already wrap with `%w`. Do not add nolint — re-check the import block ordering first (`goimports` with the local prefix). + +- [ ] **Step 5: Record and commit** + +Append to spec §14.1 under a `**Task 1 — internal/git**` heading. Known going in — record it even if nothing else surprised you: + +> `WorktreeRunner` has **12** methods, not the 13 §5 claims, so `git.Runner` is a 13-method union rather than 14. §14.1's prompt "did `git.Runner` as a 14-method union cause pain in test fakes?" is asking about the wrong number. + +Add anything else the move surfaced — `wrapcheck` or `goimports` fighting the new package boundary, parser tests that did not survive the split. + +```bash +git add internal/git internal/worktree internal/project docs/superpowers/specs +git commit -m "refactor: rehouse git exec into internal/git + +WorktreeRunner and GitRunner lived in two domain packages that both +shelled out to git. Move both to internal/git behind a Runner union; +the old packages keep aliases until their logic follows." +``` + +--- + +### Task 2: `internal/herd` skeleton — identity, config, paths + +No behaviour moves yet. This task builds the thing that makes the defect impossible: a `Ref` you cannot obtain without a profile. + +**Files:** +- Create: `internal/herd/herd.go` +- Create: `internal/herd/errors.go` +- Create: `internal/herd/paths.go` +- Create: `internal/herd/herd_test.go` +- Create: `internal/herd/paths_test.go` +- Create: `internal/herd/fakes_test.go` + +**Interfaces:** +- Consumes: `git.Runner`, `git.RealRunner` (Task 1); `tmux.Runner`, `tmux.NewClient`; `config.Config`, `config.ProfileRegistry`, `config.LoadProfile`, `config.RepoPath`, `config.HooksConfig`; `hooks.Hook`, `hooks.New`; `semconv.*`. +- Produces: everything in the code blocks below. Tasks 3-5 hang methods off `*Herd` and use `h.cfg`, `h.git`, `h.tmux`, `h.profile`, `h.hookFor`, `h.cloneDir`, `h.worktreePath`, `h.projectNames`. + +- [ ] **Step 1: Write the failing tests** + +`internal/herd/herd_test.go`: + +```go +package herd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" +) + +// h.Ref takes no profile argument, so the shortest path is the correct one. +// This is the whole point of the collapse: the profile-blind Ref cannot be +// spelled without visibly hand-building the struct. +func TestRef_carriesActiveProfile(t *testing.T) { + h := New(&config.Config{}, &config.ProfileRegistry{Active: "work"}, Deps{}) + ref := h.Ref("myapp", "feat") + + if ref.Profile != "work" { + t.Errorf("Profile = %q, want %q", ref.Profile, "work") + } + if got := ref.CanonicalName(); got != "work-myapp-feat" { + t.Errorf("CanonicalName() = %q, want %q", got, "work-myapp-feat") + } +} + +// A nil registry is what config.Load returns when profiles are off. New must +// not panic on it — the spec's own §8.1 sample did. +func TestNew_nilRegistryMeansNoProfile(t *testing.T) { + h := New(&config.Config{}, nil, Deps{}) + ref := h.Ref("myapp", "feat") + + if ref.Profile != "" { + t.Errorf("Profile = %q, want empty", ref.Profile) + } + if got := ref.CanonicalName(); got != "myapp-feat" { + t.Errorf("CanonicalName() = %q, want %q", got, "myapp-feat") + } +} + +func TestRef_tmuxNameDiffersByType(t *testing.T) { + h := New(&config.Config{}, &config.ProfileRegistry{Active: "work"}, Deps{}) + ref := h.Ref("myapp", "feat/login") + + if got := ref.tmuxName(SessionTypeAgent); got != "work-myapp-feat-login" { + t.Errorf("agent tmuxName = %q", got) + } + if got := ref.tmuxName(SessionTypeShell); got != "work-myapp-feat-login~sh" { + t.Errorf("shell tmuxName = %q", got) + } +} + +func TestWithProfile_swapsConfigAndProfile(t *testing.T) { + dir := t.TempDir() + toml := "[projects.myapp]\nrepo = \"git@github.com:user/other.git\"\n" + if err := os.WriteFile(filepath.Join(dir, "home.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + reg := &config.ProfileRegistry{Active: "work", Names: []string{"work", "home"}, ProfilesDir: dir} + h := New(&config.Config{}, reg, Deps{}) + + next, err := h.WithProfile("home") + if err != nil { + t.Fatalf("WithProfile: %v", err) + } + if next.Ref("myapp", "feat").Profile != "home" { + t.Error("new Herd did not adopt the home profile") + } + if next.Config().Projects["myapp"].Repo != "git@github.com:user/other.git" { + t.Error("new Herd did not adopt the home config") + } + if h.Ref("myapp", "feat").Profile != "work" { + t.Error("WithProfile mutated the receiver; it must return a new Herd") + } +} + +func TestWithProfile_errorsWhenProfilesDisabled(t *testing.T) { + h := New(&config.Config{}, nil, Deps{}) + if _, err := h.WithProfile("work"); err == nil { + t.Fatal("want error when profiles are disabled, got nil") + } +} + +func TestHookFor_defaultsToConfiguredHooks(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + h := New(cfg, nil, Deps{}) + if h.hookFor("myapp") == nil { + t.Error("hookFor returned nil for a configured project") + } + if h.hookFor("nonexistent") == nil { + t.Error("hookFor returned nil for an unconfigured project; it must be total") + } +} +``` + +`internal/herd/paths_test.go`: + +```go +package herd + +import ( + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" +) + +func pathsHerd(t *testing.T) (*Herd, string) { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + return New(cfg, nil, Deps{}), dir +} + +func TestCloneDir_derivedFromRepoURL(t *testing.T) { + h, dir := pathsHerd(t) + got, err := h.cloneDir("myapp") + if err != nil { + t.Fatalf("cloneDir: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp") + if got != want { + t.Errorf("cloneDir = %q, want %q", got, want) + } +} + +func TestWorktreePath_flattensBranch(t *testing.T) { + h, dir := pathsHerd(t) + got, err := h.worktreePath(h.Ref("myapp", "feat/login")) + if err != nil { + t.Fatalf("worktreePath: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat-login") + if got != want { + t.Errorf("worktreePath = %q, want %q", got, want) + } +} + +func TestPaths_unconfiguredProject(t *testing.T) { + h, _ := pathsHerd(t) + if _, err := h.cloneDir("nope"); err == nil { + t.Error("want error for unconfigured project, got nil") + } +} + +func TestProjectNames_sortedOrAll(t *testing.T) { + h, _ := pathsHerd(t) + h.cfg.Projects["alpha"] = config.ProjectConfig{Repo: "git@github.com:user/alpha.git"} + + all, err := h.projectNames("") + if err != nil { + t.Fatalf("projectNames(\"\"): %v", err) + } + if len(all) != 2 || all[0] != "alpha" || all[1] != "myapp" { + t.Errorf("projectNames(\"\") = %v, want [alpha myapp]", all) + } + + one, err := h.projectNames("myapp") + if err != nil { + t.Fatalf("projectNames(\"myapp\"): %v", err) + } + if len(one) != 1 || one[0] != "myapp" { + t.Errorf("projectNames(\"myapp\") = %v", one) + } + if _, err := h.projectNames("nope"); err == nil { + t.Error("want error for unconfigured project, got nil") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/herd/...` +Expected: FAIL — `no Go files in .../internal/herd` (the package does not exist yet). + +- [ ] **Step 3: Write `internal/herd/herd.go`** + +```go +// Package herd is codeherd's domain. It owns projects, worktrees, and the +// tmux sessions running in them — three things that used to be three +// packages that could not see each other. +// +// The split cost us a class of defects. internal/session had no config, so +// it could not know the active profile, so every profile decision moved up +// into its callers, and one of them rebuilt a session name without the +// profile and killed nothing. Here, identity lives in one place: a Ref +// obtained from Herd.Ref always carries the profile, and every session +// lookup is keyed on a Ref. +package herd + +import ( + "fmt" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/semconv" + "github.com/xico42/codeherd/internal/tmux" +) + +// SessionType distinguishes the two kinds of session codeherd runs. Both are +// first-class: they coexist for the same Ref and are addressed the same way. +type SessionType string + +const ( + SessionTypeAgent SessionType = semconv.SessionTypeAgent + SessionTypeShell SessionType = semconv.SessionTypeShell +) + +// Status is an agent session's lifecycle state, stored on the tmux session. +type Status string + +const ( + StatusRunning Status = semconv.StatusRunning + StatusWaiting Status = semconv.StatusWaiting +) + +// RemoteBranch is one remote-tracking branch. Aliased rather than redeclared: +// git.Runner returns these, and a conversion loop at the exec boundary would +// buy nothing. +type RemoteBranch = git.RemoteBranch + +// Ref identifies a workspace — a project and branch, scoped to a profile. +// +// Branch is ALWAYS the identity branch: the branch the worktree was created +// for, which is what its sessions were named after. It is never the branch +// HEAD currently points at. Use Workspace.DisplayBranch for rendering. +// +// Obtain a Ref from Herd.Ref or from Workspace.Ref. Never build one by hand. +// Herd.Ref takes no profile argument, so the shortest path is the correct +// one; a hand-built herd.Ref{Project: p, Branch: b} is visibly missing a +// field under review, and that missing field is the bug this package exists +// to prevent. +type Ref struct { + Profile string + Project string + Branch string +} + +// CanonicalName is the session name frozen at creation: the identity both +// session types share, and the key every tmux lookup matches on. +func (r Ref) CanonicalName() string { + return semconv.SessionName(r.Profile, r.Project, r.Branch) +} + +// tmuxName is the actual tmux session name for a type. It differs from +// CanonicalName only for shell sessions, which carry a ~sh suffix so the two +// types can coexist. +func (r Ref) tmuxName(t SessionType) string { + if t == SessionTypeShell { + return semconv.ShellSessionName(r.Profile, r.Project, r.Branch) + } + return semconv.SessionName(r.Profile, r.Project, r.Branch) +} + +// Deps holds the exec-boundary runners. Two fields; revisit options at three. +type Deps struct { + Tmux tmux.Runner + Git git.Runner +} + +// Herd is the domain: config, the active profile, and the runners. +type Herd struct { + cfg *config.Config + profile string + profilesDir string + profiles []string + tmux *tmux.Client + git git.Runner + + // newHook builds the hook dispatcher for one project's hook config. + // + // It is a defaulted field, not a constructor parameter, and that is + // deliberate. Binding hooks at construction is what killed dependency + // injection in the TUI: the actions needed a project-bound hook, so + // every one of them rebuilt its own service and Model.sesSvc became a + // field that was assigned and never read. Herd holds cfg, so it can + // resolve hooks per operation instead. Tests override this field. + newHook func(config.HooksConfig) hooks.Hook +} + +// New builds a Herd for the given config and profile registry. A nil +// registry means profile mode is off — that is what config.Load returns in +// the common case, so New must accept it. +func New(cfg *config.Config, registry *config.ProfileRegistry, deps Deps) *Herd { + h := &Herd{ + cfg: cfg, + tmux: tmux.NewClient(deps.Tmux), + git: deps.Git, + newHook: func(hc config.HooksConfig) hooks.Hook { return hooks.New(hc) }, + } + if registry != nil { + h.profile = registry.Active + h.profilesDir = registry.ProfilesDir + h.profiles = registry.Names + } + return h +} + +// Ref supplies the active profile. This is the only sanctioned way to mint a +// Ref from a (project, branch) pair. +func (h *Herd) Ref(project, branch string) Ref { + return Ref{Profile: h.profile, Project: project, Branch: branch} +} + +// Config exposes the config this Herd was built for. Front ends need it for +// agent lookup and project enumeration. +func (h *Herd) Config() *config.Config { return h.cfg } + +// Profile returns the active profile name, or "" when profile mode is off. +func (h *Herd) Profile() string { return h.profile } + +// Profiles returns every discovered profile name, nil when profile mode is off. +func (h *Herd) Profiles() []string { return h.profiles } + +// WithProfile returns a new Herd scoped to a different profile, sharing this +// one's runners. The receiver is unchanged. +func (h *Herd) WithProfile(name string) (*Herd, error) { + if h.profilesDir == "" { + return nil, fmt.Errorf("cannot switch to profile %q: profiles are not enabled", name) + } + cfg, err := config.LoadProfile(h.profilesDir, name) + if err != nil { + return nil, fmt.Errorf("loading profile %s: %w", name, err) + } + next := *h + next.cfg = cfg + next.profile = name + return &next, nil +} + +// hookFor returns the hook dispatcher for a project. It is total: an +// unconfigured project yields a dispatcher with no hooks, which fires nothing. +func (h *Herd) hookFor(project string) hooks.Hook { + return h.newHook(h.cfg.Projects[project].Hooks) +} +``` + +- [ ] **Step 4: Write `internal/herd/errors.go`** + +One vocabulary. Every sentinel that lived in `session`, `worktree`, or `project` lands here. + +```go +package herd + +import ( + "errors" + "fmt" +) + +// Sentinels. These are the whole error vocabulary of the domain; front ends +// match these and nothing else. They span three packages today, which is why +// cmd/errors.go grew two translators that both handle ErrNotCloned and print +// different text for it. +var ( + ErrNotCloned = errors.New("project not cloned") + ErrAlreadyCloned = errors.New("already cloned") + ErrWorktreeExists = errors.New("worktree already exists") + ErrWorktreeNotFound = errors.New("worktree not found") + ErrLocalBranchExists = errors.New("local branch already exists") + ErrSessionExists = errors.New("session already exists") + ErrSessionNotFound = errors.New("session not found") + ErrSessionRunning = errors.New("session is running") + ErrPathNotFound = errors.New("worktree path not found") +) + +// AlreadyClonedError carries the path that already exists. +type AlreadyClonedError struct{ Path string } + +func (e *AlreadyClonedError) Error() string { return e.Path + " already exists, skipping" } +func (e *AlreadyClonedError) Unwrap() error { return ErrAlreadyCloned } + +// SessionExistsError is returned by Launch when a session for the same Ref +// and type is already running. It carries the Ref so a front end can print an +// attach hint without re-deriving identity. +type SessionExistsError struct { + Ref Ref + Type SessionType +} + +func (e *SessionExistsError) Error() string { + return fmt.Sprintf("%s: %s/%s (%s)", ErrSessionExists.Error(), e.Ref.Project, e.Ref.Branch, e.Type) +} + +func (e *SessionExistsError) Unwrap() error { return ErrSessionExists } +``` + +- [ ] **Step 5: Write `internal/herd/paths.go`** + +Bodies move from `worktree.go:305-318` (`resolvePaths`), `447-457` (`cloneDirFor`), and `674-687` (`projectNames`), split into single-purpose helpers. `internal/worktree` keeps its copies until Task 5 deletes the package — one task of duplication, deliberately. + +```go +package herd + +import ( + "fmt" + "sort" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" +) + +// repoPath returns the filesystem-relative path derived from a project's repo +// URL, e.g. github.com/user/myapp. +func (h *Herd) repoPath(project string) (string, error) { + p, ok := h.cfg.Projects[project] + if !ok { + return "", fmt.Errorf("project %q is not configured", project) + } + rp, err := config.RepoPath(p.Repo) + if err != nil { + return "", fmt.Errorf("parsing repo URL %q: %w", p.Repo, err) + } + return rp, nil +} + +// cloneDir returns the main git clone directory for a project. +func (h *Herd) cloneDir(project string) (string, error) { + rp, err := h.repoPath(project) + if err != nil { + return "", err + } + return semconv.CloneDir(h.cfg.Defaults.ProjectsDir, rp), nil +} + +// worktreesRoot returns the directory holding a project's worktrees. +func (h *Herd) worktreesRoot(project string) (string, error) { + rp, err := h.repoPath(project) + if err != nil { + return "", err + } + return semconv.WorktreesRoot(h.cfg.Defaults.ProjectsDir, rp), nil +} + +// worktreePath returns the filesystem path for a ref's worktree. It derives +// from Ref.Branch — the identity branch — so it agrees with the session name +// by construction. +func (h *Herd) worktreePath(ref Ref) (string, error) { + rp, err := h.repoPath(ref.Project) + if err != nil { + return "", err + } + return semconv.WorktreePath(h.cfg.Defaults.ProjectsDir, rp, ref.Branch), nil +} + +// projectNames returns sorted project names, or just the named one after +// validating it exists. +func (h *Herd) projectNames(project string) ([]string, error) { + if project != "" { + if _, ok := h.cfg.Projects[project]; !ok { + return nil, fmt.Errorf("project %q is not configured", project) + } + return []string{project}, nil + } + names := make([]string, 0, len(h.cfg.Projects)) + for name := range h.cfg.Projects { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} +``` + +- [ ] **Step 6: Write `internal/herd/fakes_test.go`** + +The one shared fake set. Later tasks add per-test overrides by assigning func fields; they do not hand-roll new runners. + +```go +package herd + +import ( + "fmt" + "strings" + "sync" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/hooks" +) + +// fakeGit satisfies the whole git.Runner union. Every method is a func field +// defaulting to success, so a test overrides only what it cares about: +// +// g := &fakeGit{} +// g.AddFn = func(_, _, _ string) error { return errors.New("boom") } +type fakeGit struct { + mu sync.Mutex + Calls []string // " …", in order + + AddFn func(cloneDir, worktreePath, branch string) error + AddNewBranchFn func(cloneDir, worktreePath, branch string) error + AddNewBranchFromFn func(cloneDir, worktreePath, branch, startPoint string) error + RemoveFn func(cloneDir, worktreePath string) error + ListFn func(cloneDir string) ([]git.WorktreeInfo, error) + FetchFn func(cloneDir, remote, branch string) error + FetchAllFn func(cloneDir string) error + FastForwardFn func(cloneDir, remote, branch string) error + RemotesFn func(cloneDir string) ([]string, error) + ListRemoteBranchesFn func(cloneDir string) ([]git.RemoteBranch, error) + AddTrackingFn func(cloneDir, worktreePath, branch, remoteRef string) error + HasLocalBranchFn func(cloneDir, branch string) (bool, error) + CloneFn func(repo, path, branch string) error +} + +func (g *fakeGit) record(parts ...string) { + g.mu.Lock() + defer g.mu.Unlock() + g.Calls = append(g.Calls, strings.Join(parts, " ")) +} + +// called reports whether any recorded call contains all the given substrings. +func (g *fakeGit) called(want ...string) bool { + g.mu.Lock() + defer g.mu.Unlock() + for _, c := range g.Calls { + ok := true + for _, w := range want { + if !strings.Contains(c, w) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +func (g *fakeGit) Add(cloneDir, worktreePath, branch string) error { + g.record("Add", cloneDir, worktreePath, branch) + if g.AddFn != nil { + return g.AddFn(cloneDir, worktreePath, branch) + } + return nil +} + +func (g *fakeGit) AddNewBranch(cloneDir, worktreePath, branch string) error { + g.record("AddNewBranch", cloneDir, worktreePath, branch) + if g.AddNewBranchFn != nil { + return g.AddNewBranchFn(cloneDir, worktreePath, branch) + } + return nil +} + +func (g *fakeGit) AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error { + g.record("AddNewBranchFrom", cloneDir, worktreePath, branch, startPoint) + if g.AddNewBranchFromFn != nil { + return g.AddNewBranchFromFn(cloneDir, worktreePath, branch, startPoint) + } + return nil +} + +func (g *fakeGit) Remove(cloneDir, worktreePath string) error { + g.record("Remove", cloneDir, worktreePath) + if g.RemoveFn != nil { + return g.RemoveFn(cloneDir, worktreePath) + } + return nil +} + +func (g *fakeGit) List(cloneDir string) ([]git.WorktreeInfo, error) { + g.record("List", cloneDir) + if g.ListFn != nil { + return g.ListFn(cloneDir) + } + return nil, nil +} + +func (g *fakeGit) Fetch(cloneDir, remote, branch string) error { + g.record("Fetch", cloneDir, remote, branch) + if g.FetchFn != nil { + return g.FetchFn(cloneDir, remote, branch) + } + return nil +} + +func (g *fakeGit) FetchAll(cloneDir string) error { + g.record("FetchAll", cloneDir) + if g.FetchAllFn != nil { + return g.FetchAllFn(cloneDir) + } + return nil +} + +func (g *fakeGit) FastForward(cloneDir, remote, branch string) error { + g.record("FastForward", cloneDir, remote, branch) + if g.FastForwardFn != nil { + return g.FastForwardFn(cloneDir, remote, branch) + } + return nil +} + +func (g *fakeGit) Remotes(cloneDir string) ([]string, error) { + g.record("Remotes", cloneDir) + if g.RemotesFn != nil { + return g.RemotesFn(cloneDir) + } + return []string{"origin"}, nil +} + +func (g *fakeGit) ListRemoteBranches(cloneDir string) ([]git.RemoteBranch, error) { + g.record("ListRemoteBranches", cloneDir) + if g.ListRemoteBranchesFn != nil { + return g.ListRemoteBranchesFn(cloneDir) + } + return nil, nil +} + +func (g *fakeGit) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + g.record("AddTracking", cloneDir, worktreePath, branch, remoteRef) + if g.AddTrackingFn != nil { + return g.AddTrackingFn(cloneDir, worktreePath, branch, remoteRef) + } + return nil +} + +func (g *fakeGit) HasLocalBranch(cloneDir, branch string) (bool, error) { + g.record("HasLocalBranch", cloneDir, branch) + if g.HasLocalBranchFn != nil { + return g.HasLocalBranchFn(cloneDir, branch) + } + return false, nil +} + +func (g *fakeGit) Clone(repo, path, branch string) error { + g.record("Clone", repo, path, branch) + if g.CloneFn != nil { + return g.CloneFn(repo, path, branch) + } + return nil +} + +// fakeTmux satisfies tmux.Runner. Sessions is the raw list-sessions table it +// serves; Calls records every invocation. +type fakeTmux struct { + mu sync.Mutex + Sessions []sessionRow + Calls [][]string + RunFn func(args ...string) (string, string, int, error) // overrides everything +} + +// sessionRow is one record in the fake's list-sessions table, in the field +// order tmux.Client.ListSessions parses. +type sessionRow struct { + ID, Name, Canonical, Type, Status, Annotation, StartedAt, Profile, Branch, Project string +} + +func (r sessionRow) format() string { + return strings.Join([]string{ + r.ID, r.Name, r.Canonical, r.Type, r.Status, + r.Annotation, r.StartedAt, r.Profile, r.Branch, r.Project, + }, "\t") +} + +func (f *fakeTmux) Run(args ...string) (string, string, int, error) { + f.mu.Lock() + f.Calls = append(f.Calls, args) + f.mu.Unlock() + + if f.RunFn != nil { + return f.RunFn(args...) + } + switch args[0] { + case "list-sessions": + if len(f.Sessions) == 0 { + return "", "", 1, nil // tmux exits 1 when there are no sessions + } + rows := make([]string, len(f.Sessions)) + for i, s := range f.Sessions { + rows[i] = s.format() + } + return strings.Join(rows, "\n"), "", 0, nil + case "new-session": + return "$1", "", 0, nil + case "has-session": + return "", "", 1, nil + } + return "", "", 0, nil +} + +// called reports whether any recorded tmux invocation contains all the given +// substrings, in any position. +func (f *fakeTmux) called(want ...string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.Calls { + joined := strings.Join(c, " ") + ok := true + for _, w := range want { + if !strings.Contains(joined, w) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +// killed returns every kill-session target, in order. +func (f *fakeTmux) killed() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.Calls { + if len(c) >= 3 && c[0] == "kill-session" { + out = append(out, c[2]) + } + } + return out +} + +// mockHook records hook triggers and can fail a named one. +type mockHook struct { + calls []hookCall + failOn string +} + +type hookCall struct { + name string + attrs map[string]string + workDir string +} + +func (m *mockHook) Trigger(name string, attrs map[string]string, workDir string) error { + m.calls = append(m.calls, hookCall{name, attrs, workDir}) + if m.failOn == name { + return fmt.Errorf("hook %s failed", name) + } + return nil +} + +// withHook forces every operation on h to use the given hook, bypassing +// config lookup. This is the seam that keeps the hook tests intact. +func withHook(h *Herd, m hooks.Hook) *Herd { + h.newHook = func(config.HooksConfig) hooks.Hook { return m } + return h +} +``` + +**About `sessionRow.Project`:** `tmux.SessionRecord.Project` does not exist yet — Task 4 adds it, along with widening `ListSessions`'s `SplitN(line, "\t", 9)` to 10. Until then, `SplitN` caps at 9 substrings, so a row with a non-empty `Project` would leave `Branch` holding `"feat\tmyapp"`. Declaring the field now keeps `fakes_test.go` stable across Task 4, but **no test may set `Project` (or use `Sessions` at all) until Task 4 Step 1 is done** — Tasks 2 and 3 do not, so this is safe. Task 4's `TestSessions_rebuildsCompleteRef` is what proves the widening landed. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `go test ./internal/herd/... -v` +Expected: PASS for all of `TestRef_carriesActiveProfile`, `TestNew_nilRegistryMeansNoProfile`, `TestRef_tmuxNameDiffersByType`, `TestWithProfile_swapsConfigAndProfile`, `TestWithProfile_errorsWhenProfilesDisabled`, `TestHookFor_defaultsToConfiguredHooks`, and the five path tests. + +`fakeGit`, `fakeTmux`, and `mockHook` are unused this task. Go does not complain about unused types, but if `golangci-lint` flags `withHook` as unused, leave it — Task 3 uses it. If the linter blocks the commit, note it and move `fakes_test.go` into Task 3 instead of adding a nolint. + +- [ ] **Step 8: Verify, record, and commit** + +Run: `make check` +Expected: green. Coverage rises slightly — `herd` lands well covered. + +Append to spec §14.1 under `**Task 2 — herd skeleton**`. Record the two API decisions this task locks in, because every later plan codes against them: + +> `New` takes `(cfg, registry, deps)`, not `(cfg, profile, deps)` — `WithProfile` needs `ProfilesDir`, and §8.1's `herd.New(cfg, registry.Active, …)` sample nil-panics when profiles are off. `cmd.activeProfile()` is gone as a result. +> +> Hooks are resolved per operation via an unexported `newHook func(config.HooksConfig) hooks.Hook` field on `Herd`, defaulted in `New` and overridden by tests. §6 said hooks would not appear at all; they do not appear in the *exported* API, which is what §3.2's constraint actually requires. + +Also record whether the `withHook` / `fakeGit` / `fakeTmux` set in `fakes_test.go` tripped the `unused` linter before Task 3 consumed it (Step 7 flags this as a possibility). + +```bash +git add internal/herd docs/superpowers/specs +git commit -m "feat: add internal/herd skeleton — identity, config, paths + +Herd holds cfg plus the active profile, so Ref always carries the +profile and h.Ref takes no profile argument. That is the whole +mechanism: the shortest path to a Ref is the correct one, and the +profile-blind SessionName(\"\", …) literal has nowhere to live. + +No behaviour moves yet." +``` + +--- + +### Task 3: project domain → `herd` + +First domain in. `project` has no dependencies — it never imports `tmux` — so it moves cleanest (spec §12.1). Its callers migrate with it, and `internal/project` is deleted in the same commit. + +**Files:** +- Create: `internal/herd/project.go` +- Create: `internal/herd/project_test.go` (moved from `internal/project/project_test.go`) +- Modify: `cmd/root.go:14-44` (composition root) +- Modify: `cmd/services.go:28-42` (delete `newProjectService`, `activeProfile`) +- Modify: `cmd/project.go` (all three commands) +- Modify: `cmd/tui.go:119-125` +- Modify: `internal/tui/model.go` (`Model.projSvc` → `Model.herd`; `profileCache`) +- Modify: `internal/tui/actions.go:108-109,202-203,264-265` +- Modify: `internal/tui/form.go:137-138` +- Modify: `internal/tui/agent_picker.go:98-99` +- Delete: `internal/project/` (both files) + +**Interfaces:** +- Consumes: `Herd`, `Ref`, `hookFor`, `cloneDir`, `projectNames`, `ErrAlreadyCloned`, `AlreadyClonedError` (Task 2). +- Produces: + ```go + type Project struct { + Name string + Config config.ProjectConfig + Path string // absolute, derived from repo URL + projects_dir + Cloned bool + } + + func (h *Herd) Projects() []Project // all, sorted, no filesystem access + func (h *Herd) Project(name string) (Project, error) // one, with Cloned status + func (h *Herd) Clone(project string) error // *AlreadyClonedError if the path exists + ``` + Plus, in `cmd`: the package-level `var h *herd.Herd`, which every later task's `cmd` code uses. + +- [ ] **Step 1: Move the tests** + +Move `internal/project/project_test.go` → `internal/herd/project_test.go`. Transform: +- line 1: `package project_test` → `package herd` +- Delete its `mockGitRunner` (15-20), `cloneCall` (20), `mockHook` (30), `hookCall` (35) — `fakes_test.go` supplies all four. `mockGitRunner` becomes `fakeGit` with `CloneFn`; assert with `g.called("Clone", repo, path)` instead of inspecting `cloneCall` slices. +- `makeConfig` (49) stays, but returns a `*Herd`: rename to `projectHerd(t, projectsDir, projects) (*Herd, *fakeGit)` and have it call `New(cfg, nil, Deps{Git: g})`. +- `project.NewService(cfg, git, hook)` → `withHook(projectHerd(…))` +- `svc.List()` → `h.Projects()`, `svc.Show(n)` → `h.Project(n)`, `svc.Clone(n)` → `h.Clone(n)` +- `project.ErrAlreadyCloned` → `ErrAlreadyCloned`, `*project.AlreadyClonedError` → `*AlreadyClonedError` +- **Delete `TestCloneAll_MixedResults` (246) and `TestCloneAll_Empty` (282).** `CloneAll` has no non-test callers and does not move. + +Add one test the old package could not have had — that `Clone` is reachable through the same `Herd` the rest of the domain uses: + +```go +func TestClone_underProfile_usesProfileConfig(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "trunk"}, + }, + } + g := &fakeGit{} + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Git: g}) + + if err := h.Clone("myapp"); err != nil { + t.Fatalf("Clone: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp") + if !g.called("Clone", "git@github.com:user/myapp.git", want, "trunk") { + t.Errorf("clone did not target %s; calls=%v", want, g.Calls) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/herd/...` +Expected: FAIL — `undefined: Project`, `h.Projects undefined`, `h.Clone undefined`. + +- [ ] **Step 3: Write `internal/herd/project.go`** + +Bodies move from `internal/project/project.go`: `List` (78-95), `Show` (98-115), `Clone` (119-155). The only real change is that path derivation goes through `h.cloneDir` instead of being inlined three times. + +```go +package herd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" +) + +// Project is a configured project with its derived clone path. +type Project struct { + Name string + Config config.ProjectConfig + Path string // absolute path derived from repo URL + projects_dir + Cloned bool // true if Path exists on the filesystem +} + +// Projects returns every configured project sorted by name. It does not touch +// the filesystem, so Cloned is always false — use Project for that. +func (h *Herd) Projects() []Project { + names, _ := h.projectNames("") // "" cannot error + entries := make([]Project, 0, len(names)) + for _, name := range names { + path, _ := h.cloneDir(name) // unparseable repo URL yields an empty path + entries = append(entries, Project{ + Name: name, + Config: h.cfg.Projects[name], + Path: path, + }) + } + return entries +} + +// Project returns one project including its Cloned status. +func (h *Herd) Project(name string) (Project, error) { + path, err := h.cloneDir(name) + if err != nil { + return Project{}, err + } + _, statErr := os.Stat(path) + return Project{ + Name: name, + Config: h.cfg.Projects[name], + Path: path, + Cloned: statErr == nil, + }, nil +} + +// Clone clones a project's repo into its derived path under projects_dir. +// Returns *AlreadyClonedError (wrapping ErrAlreadyCloned) if the path exists. +func (h *Herd) Clone(project string) error { + path, err := h.cloneDir(project) + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + return &AlreadyClonedError{Path: path} + } + + p := h.cfg.Projects[project] + hook := h.hookFor(project) + attrs := map[string]string{ + semconv.HookAttrProject: project, + semconv.HookAttrRepo: p.Repo, + semconv.HookAttrCloneDir: path, + } + + if err := hook.Trigger(semconv.HookPreClone, attrs, h.cfg.Defaults.ProjectsDir); err != nil { + return fmt.Errorf("pre-clone hook: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating parent directories: %w", err) + } + if err := h.git.Clone(p.Repo, path, p.DefaultBranch); err != nil { + return fmt.Errorf("cloning repository: %w", err) + } + if err := hook.Trigger(semconv.HookPostClone, attrs, h.cfg.Defaults.ProjectsDir); err != nil { + return fmt.Errorf("post-clone hook: %w", err) + } + return nil +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/herd/... -v` +Expected: PASS, including the moved `TestClone_TriggersHooks` and `TestClone_PreHookFailure_StopsClone`. + +- [ ] **Step 5: Build the composition root** + +`cmd/root.go` — replace the `cfg` + `registry` globals with a single `h`. Keep `cfg` for now: Tasks 4 and 5 still read it, and Task 6 removes what is left. + +```go +var ( + cfgFile string + noTmux bool + profileFlag string + cfg *config.Config + registry *config.ProfileRegistry + // h is the domain. It is the only service any command constructs, and + // it is constructed exactly once, here. + h *herd.Herd +) +``` + +In `PersistentPreRunE` (line 36), after the existing `config.Load`: + +```go + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + cfg, registry, err = config.Load(cfgFile, resolveProfileArg(profileFlag)) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + h = herd.New(cfg, registry, herd.Deps{ + Tmux: tmux.NewRealRunner(), + Git: git.NewRealRunner(), + }) + return nil + }, +``` + +- [ ] **Step 6: Migrate the project callers** + +`cmd/services.go`: delete `newProjectService` (31-33). Delete `activeProfile` (37-42) **and** its uses — there are three (`session.go:233`, `session.go:242`, and the two shims at 47/69/89 which stay until Task 4). Replace `activeProfile()` in `cmd/session.go:233` and `:242` with `h.Profile()`. Leave `showSessionForProfile` / `stopSessionForProfile` / `listSessionsForProfile` alone; they die in Task 4. They call `activeProfile()`, so keep a local `prof := h.Profile()` in each rather than reviving the helper. + +`cmd/project.go`: +- `ListProjectCmd.Run`: `svc := newProjectService(); entries := svc.List()` → `entries := h.Projects()` +- `ShowProjectCmd.Run`: `svc.Show(args[0])` → `h.Project(args[0])` +- `CloneProjectCmd.Run`: delete both `hooks.New` + `project.NewService` blocks (107-109, 134-136). The `--all` loop calls `h.Clone(name)`; the single path calls `h.Clone(name)` then `h.Project(name)` for the path line. `*project.AlreadyClonedError` → `*herd.AlreadyClonedError`. Drop the `hooks` and `project` imports; keep `sort` (the `--all` loop still sorts) — or better, replace the loop's manual name-gathering (100-104) with `for _, p := range h.Projects()`, which is already sorted, and drop `sort` too. + +`cmd/tui.go:119-125`: +```go +func runTUIDirect(tmuxClient *tmux.Client) error { + wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, &hooks.NoOp{}) + sesSvc := newSessionService() + + insideTmux := os.Getenv("TMUX") != "" + m := tui.NewModel(cfg, wtSvc, sesSvc, h, tmuxClient, insideTmux, registry) + … +} +``` +`projSvc` is gone; `h` takes its slot. Drop the `project` import. + +`internal/tui/model.go`: +- `NewModel`: replace the `projSvc *project.Service` parameter with `herd *herd.Herd`; drop the `project` import. +- `Model`: replace `projSvc *project.Service` with `herd *herd.Herd`. +- `profileBundle` (111-115): drop the `projSvc` field; the struct keeps `cfg` and `wtSvc` until Task 5. +- `switchProfile` (594-628): delete the `project.NewService` line (613). Add `herd` to the bundle, built with `m.herd.WithProfile(next)`: + ```go + bundle, ok := m.profileCache[next] + if !ok { + nextHerd, err := m.herd.WithProfile(next) + if err != nil { + m.statusMsg = fmt.Sprintf("profile switch failed: %v", err) + return m, nil + } + cfg := nextHerd.Config() + wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), m.tmuxClient, &hooks.NoOp{}) + bundle = profileBundle{cfg: cfg, wtSvc: wtSvc, herd: nextHerd} + m.profileCache[next] = bundle + } + m.cfg = bundle.cfg + m.wtSvc = bundle.wtSvc + m.herd = bundle.herd + ``` + `config.LoadProfile` is now called inside `WithProfile`, so drop the direct `config.LoadProfile` call and, if nothing else in the file uses it, the `config` import stays (it is used for `*config.Config` fields). +- `NewModel`'s seed (147-149): `m.profileCache[registry.Active] = profileBundle{cfg: cfg, wtSvc: wtSvc, herd: herdArg}`. + +`internal/tui/actions.go`, `form.go`, `agent_picker.go` — three identical shapes. Each has: +```go + projSvc := projectpkg.NewService(cfg, projectpkg.NewRealGitRunner(), h) + _ = projSvc.Clone(project) +``` +Replace each with `_ = hrd.Clone(project)`, where `hrd` is the `*herd.Herd` captured alongside `cfg` in the enclosing closure. Sites: `actions.go:108-109` and `202-203` and `264-265` (this last one is `cloneAction`, which checks the error — keep `if err := hrd.Clone(project); err != nil { return errMsg{err: err} }`), `form.go:137-138`, `agent_picker.go:98-99`. + +`form.go` and `agent_picker.go` capture services through struct fields (`formModel.cfg` / `agentPickerPending.cfg`). Add a `herd *herd.Herd` field beside each `cfg` field and populate it at construction — `newFormModel(ctx, m.cfg, m.tmuxClient)` becomes `newFormModel(ctx, m.cfg, m.herd, m.tmuxClient)`, and `agentPickerPending` gains `herd: m.herd`. Update `showForm`, `showTrackForm` (`model.go:692`), and both `newAgentPicker` call sites (`actions.go:84`, `:139`) accordingly. + +Drop the `projectpkg` import from all three files. + +- [ ] **Step 7: Delete `internal/project`** + +```bash +git rm -r internal/project +``` + +Run: `go build ./... && go vet ./...` +Expected: clean. Any remaining reference to `internal/project` is a caller Step 6 missed — `git grep -n 'internal/project'` must return nothing. + +- [ ] **Step 8: Verify** + +Run: `make check` +Expected: green, ≥80%. + +If coverage dropped: the moved project tests did not come with the code. `go test -coverprofile=/tmp/c.out ./internal/herd/... && go tool cover -func=/tmp/c.out | grep project.go` — every function in `project.go` should show non-zero. + +- [ ] **Step 9: Record and commit** + +Append to spec §14.1 under `**Task 3 — project domain**`. Known going in: + +> `CloneAll` was dropped rather than moved — zero non-test callers; `cmd/project.go` ran its own loop. Its two tests were deleted. +> +> `Project(name) (Project, error)` was added for `ch show project`, which needs one project with `Cloned` status. §6 listed only `Projects()`. + +This is the first stage to migrate front-end callers, so it is the first real evidence for §13's "project folding is the weakest link" worry. Record your read: did folding `project` in earn its place, or does it still look like the first cut to reverse? + +```bash +git add -A +git commit -m "refactor: fold project into internal/herd + +First domain in. project never imported tmux, so it moves cleanest and +proves the shape: Clone resolves its own hook from cfg instead of taking +one at construction, which is what let cmd and tui stop building +services. + +cmd/root.go now constructs the one Herd every command uses. CloneAll is +dropped — it had no non-test callers." +``` + +--- + +### Task 4: session domain → `herd` + +The heart of it. `session.Service` is the package with no config (spec §2.1) — the asymmetry that made a session addressable only if you got lucky. After this task there is exactly one lookup path, and it is keyed on a `Ref` that carries the profile. + +**Files:** +- Create: `internal/herd/session.go` +- Create: `internal/herd/session_test.go` (moved from `internal/session/session_test.go`) +- Modify: `internal/tmux/client.go:10-21,228-262` (add `@codeherd_project`) +- Modify: `internal/tmux/client_test.go` (the list-sessions format assertions) +- Modify: `cmd/services.go` (delete the three `*ForProfile` shims and `newSessionService`) +- Modify: `cmd/session.go` (all four commands) +- Modify: `cmd/worktree.go:182-198` (the `--attach` session start) +- Modify: `cmd/plugin.go:47-63` +- Modify: `cmd/errors.go:31-53` +- Modify: `cmd/tui.go:119-125` +- Modify: `internal/tui/model.go` (drop `Model.sesSvc` — it is assigned and never read) +- Modify: `internal/tui/actions.go` (four `session.NewService` + `Start` sites) +- Modify: `internal/tui/agent_picker.go:113-127` +- Modify: `internal/tui/delete_teardown_test.go` +- Modify: `cmd/services_test.go`, `cmd/session_internal_test.go` +- Delete: `internal/session/` (both files) + +**Interfaces:** +- Consumes: everything from Tasks 2 and 3, plus `h.worktreePath`, `h.cloneDir`. +- Produces: + ```go + type Handle struct { + ID string // tmux session_id ("$1") — stable across renames + Ref Ref + Type SessionType + TmuxName string // current tmux name; may carry the ⚡ status prefix + Status Status + Annotation string + StartedAt time.Time + } + + type LaunchOpts struct { + Type SessionType // zero value SessionTypeAgent + Agent string // agent name; "" means defaults.agent. Ignored for shell. + Attach bool + } + + type StopOpts struct { + Type SessionType // ignored when All is true + All bool // stop every type for this Ref + } + + func (h *Herd) Launch(ref Ref, opts LaunchOpts) (Handle, error) + func (h *Herd) Resolve(ref Ref, t SessionType) (Handle, error) + func (h *Herd) Sessions() ([]Handle, error) // profile-filtered + func (h *Herd) StopSessions(ref Ref, opts StopOpts) ([]Handle, error) // stopped handles + func (h *Herd) SetStatus(canonicalName string, status Status, annotation string) error + ``` + Task 5 calls `h.StopSessions` from `Teardown`. + +- [ ] **Step 1: Stamp the project on the session** + +`Ref.Project` cannot be recovered from a tmux record today: `work-myapp-feat` could be profile `work` + project `myapp`, or a project literally named `work-myapp` (spec §11). Without it, `Sessions()` returns `Handle`s whose `Ref` is missing exactly one field — and a `Ref` missing only `Project` compiles into `Teardown`. Stamp it. + +`internal/tmux/client.go`, `SessionRecord` (10-21) — add after `Branch`: +```go + Project string // @codeherd_project — the project the session belongs to, "" when unset +``` + +`ListSessions` (228-262): append `\t#{@codeherd_project}` to the format string, change both `9`s to `10` (`SplitN(line, "\t", 10)` and the pad loop), and add `Project: fields[9]` to the record literal. + +`internal/semconv/semconv.go` — add beside the other option names (line 17): +```go + TmuxOptionProject = "@codeherd_project" +``` + +Sessions started before this change have no `@codeherd_project`, so their `Handle.Ref.Project` is `""`. That fails loudly and safely: `Teardown` on such a Ref returns `project "" is not configured` rather than deleting the wrong thing. Say so in a comment on the field. + +> **Superseded (2026-07-16):** this "fails loudly" guard was never actually reachable — a pre-upgrade session was silently dropped and survived teardown instead. The `session-canonical-compat` change fixed the real cause: matching now keys on the stored `@codeherd_canonical_name`, so such sessions are recognized, killed, and healed (their project recovered and re-stamped). See `docs/superpowers/specs/2026-07-16-session-canonical-compat-design.md`. + +Update `internal/tmux/client_test.go`: `git grep -n 'codeherd_branch' internal/tmux/client_test.go` finds the format assertion and the row builders. Add the tenth column to each. Add one record-level test: + +```go +func TestListSessions_parsesProject(t *testing.T) { + r := &mockRunner{stdout: "$1\twork-myapp-feat\twork-myapp-feat\tagent\trunning\t\t\twork\tfeat\tmyapp"} + got, err := NewClient(r).ListSessions() + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(got) != 1 || got[0].Project != "myapp" { + t.Errorf("Project = %q, want %q", got[0].Project, "myapp") + } +} +``` +(Match `mockRunner`'s actual field names — read the top of `client_test.go` first.) + +Run: `go test ./internal/tmux/...` — PASS before continuing. + +- [ ] **Step 2: Move the session tests** + +Move `internal/session/session_test.go` → `internal/herd/session_test.go`. Transform: +- line 1: `package session_test` → `package herd` +- Delete its `mockRunner`, `mockHook`, `hookCall`, `newService`, `findCall`, `newSessionEnv` helpers — `fakes_test.go` supplies fakes; `findCall` becomes `f.called(…)` and `newSessionEnv` becomes a local helper in `session_test.go` (it parses `new-session` args, which is session-specific). +- `session.Service` → `*Herd`, built by a local helper: + ```go + func sessionHerd(t *testing.T, f *fakeTmux) (*Herd, string) { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir, Agent: "claude"}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + Agents: map[string]config.AgentConfig{"claude": {Cmd: "claude"}}, + } + return New(cfg, nil, Deps{Tmux: f, Git: &fakeGit{}}), dir + } + ``` + (Check `config.AgentConfig`'s real field names in `internal/config/agent.go` before writing this.) +- `svc.Start(session.StartRequest{Project: p, Branch: b, Path: path, Type: …, Cmd: …, Env: …, Profile: prof})` → `h.Launch(h.Ref(p, b), LaunchOpts{Type: …, Agent: …})`. `Path` and `CloneDir` are no longer passed — `Launch` derives them. Tests that fed an arbitrary `Path` must now `os.MkdirAll` the derived worktree path under the herd's `projects_dir`; `sessionHerd` returns `dir` for exactly that. +- `svc.Show(p, b, t)` → `h.Resolve(h.Ref(p, b), t)`; `svc.Stop(p, b, t)` → `h.StopSessions(h.Ref(p, b), StopOpts{Type: t})`. +- **Delete the `ShowByName` / `StopByName` tests.** Those methods were the escape hatch for the missing profile parameter; nothing addresses a session by name any more except `SetStatus`, which keeps its own tests. +- `session.ErrSessionNotFound` → `ErrSessionNotFound`, etc. + +Add the test that names the defect. This is the one the old package could not express, because `Stop` had no profile parameter to get wrong: + +```go +// Under an active profile, sessions are named --. +// session.Service.Stop hardcoded an empty profile via SessionName("", …), so +// it searched for myapp-feat and missed work-myapp-feat entirely. Here the +// profile rides on the Ref and there is no parameter to omit. +func TestStopSessions_underProfile_matchesProfileScopedSession(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + stopped, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{Type: SessionTypeAgent}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 1 { + t.Fatalf("stopped %d sessions, want 1", len(stopped)) + } + if killed := f.killed(); len(killed) != 1 || killed[0] != "$1" { + t.Errorf("killed = %v, want [$1] — the session was addressed by name, not ID", killed) + } +} + +// StopOpts.All is what Teardown uses: both types die, addressed by ID. +func TestStopSessions_all_stopsBothTypesByID(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Profile: "work", Branch: "feat", Project: "myapp"}, + {ID: "$2", Name: "work-myapp-feat~sh", Canonical: "work-myapp-feat", + Type: "shell", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + stopped, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{All: true}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 2 { + t.Fatalf("stopped %d sessions, want 2", len(stopped)) + } + killed := f.killed() + sort.Strings(killed) + if len(killed) != 2 || killed[0] != "$1" || killed[1] != "$2" { + t.Errorf("killed = %v, want [$1 $2]", killed) + } +} + +// Stopping a session that isn't running is not an error: Teardown calls this +// unconditionally, and a worktree with no sessions is the common case. +func TestStopSessions_noneRunning_isNotAnError(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + stopped, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{All: true}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 0 { + t.Errorf("stopped = %v, want empty", stopped) + } +} + +// Launch stamps the project so Sessions can rebuild a complete Ref. +func TestLaunch_stampsProjectOption(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + if err := os.MkdirAll(filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat"), 0o755); err != nil { + t.Fatal(err) + } + + if _, err := h.Launch(h.Ref("myapp", "feat"), LaunchOpts{}); err != nil { + t.Fatalf("Launch: %v", err) + } + if !f.called("set-option", semconv.TmuxOptionProject, "myapp") { + t.Errorf("@codeherd_project was not stamped; calls=%v", f.Calls) + } +} + +// Sessions rebuilds a complete Ref from the tmux options, so a handle from a +// list can be fed straight back into Teardown. +func TestSessions_rebuildsCompleteRef(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d handles, want 1", len(got)) + } + want := Ref{Profile: "work", Project: "myapp", Branch: "feat"} + if got[0].Ref != want { + t.Errorf("Ref = %+v, want %+v", got[0].Ref, want) + } +} + +// Sessions is profile-scoped: another profile's sessions are not ours. +func TestSessions_filtersByActiveProfile(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Profile: "work", Branch: "feat", Project: "myapp"}, + {ID: "$2", Name: "home-myapp-feat", Canonical: "home-myapp-feat", + Type: "agent", Profile: "home", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions: %v", err) + } + if len(got) != 1 || got[0].ID != "$1" { + t.Errorf("got %+v, want only the work-profile session", got) + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/herd/...` +Expected: FAIL — `undefined: Handle`, `h.Launch undefined`, `h.StopSessions undefined`. + +- [ ] **Step 4: Write `internal/herd/session.go`** + +`Start` (`session.go:77-158`), `List` (176-197), and `SetStatus` (319-350) move nearly verbatim. `Show`/`ShowByName`/`Stop`/`StopByName` — four methods, one duplicated `ListSessions`-and-match loop each — collapse into `handles` + `Resolve` + `StopSessions`. + +```go +package herd + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/xico42/codeherd/internal/semconv" + "github.com/xico42/codeherd/internal/tmux" +) + +// Handle is a live session. +type Handle struct { + ID string // tmux session_id ("$1") — stable across renames + Ref Ref + Type SessionType + TmuxName string // current tmux name; may carry the ⚡ status prefix + Status Status + Annotation string + StartedAt time.Time +} + +// LaunchOpts configures a session start. The zero value starts the default +// agent, detached. +type LaunchOpts struct { + Type SessionType // zero value means SessionTypeAgent + Agent string // agent name; "" means defaults.agent. Ignored for shell. + Attach bool // front ends read Handle.ID and attach themselves +} + +// StopOpts selects which of a Ref's sessions to stop. +type StopOpts struct { + Type SessionType // ignored when All is true + All bool // stop every type for this Ref +} + +// Launch starts a detached tmux session for ref and returns its handle. +// +// The session command runs with these env vars, which override conflicting +// keys in the agent's configured Env: +// +// - CODEHERD_SESSION canonical session name +// - CODEHERD_PROJECT project name +// - CODEHERD_BRANCH identity branch +// - CODEHERD_CLONE_DIR main git clone path +// - CODEHERD_WORKTREE_PATH worktree root +// - CODEHERD_PROFILE profile name (only when a profile is active) +// +// Returns *SessionExistsError if a session for this ref and type is already +// running, and ErrPathNotFound if the worktree does not exist on disk. +func (h *Herd) Launch(ref Ref, opts LaunchOpts) (Handle, error) { + if opts.Type == "" { + opts.Type = SessionTypeAgent + } + + // Scope the existence check to (ref, type) so agent and shell sessions coexist. + switch _, err := h.Resolve(ref, opts.Type); { + case err == nil: + return Handle{}, &SessionExistsError{Ref: ref, Type: opts.Type} + case !errors.Is(err, ErrSessionNotFound): + return Handle{}, err + } + + path, err := h.worktreePath(ref) + if err != nil { + return Handle{}, err + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return Handle{}, fmt.Errorf("%w: %s", ErrPathNotFound, path) + } + return Handle{}, fmt.Errorf("checking worktree path: %w", err) + } + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return Handle{}, err + } + + cmd, env, err := h.sessionCommand(opts) + if err != nil { + return Handle{}, err + } + + canonical := ref.CanonicalName() + hook := h.hookFor(ref.Project) + attrs := map[string]string{ + semconv.HookAttrProject: ref.Project, + semconv.HookAttrBranch: ref.Branch, + semconv.HookAttrWorktreePath: path, + semconv.HookAttrSessionName: canonical, + } + if err := hook.Trigger(semconv.HookPreSession, attrs, path); err != nil { + return Handle{}, fmt.Errorf("pre-session hook: %w", err) + } + + sessionEnv := make(map[string]string, len(env)+6) + for k, v := range env { + sessionEnv[k] = v + } + // Codeherd-stamped vars win over user-supplied Env. + sessionEnv[semconv.SessionEnvVar] = canonical + sessionEnv[semconv.HookAttrProject] = ref.Project + sessionEnv[semconv.HookAttrBranch] = ref.Branch + sessionEnv[semconv.HookAttrWorktreePath] = path + if cloneDir != "" { + sessionEnv[semconv.HookAttrCloneDir] = cloneDir + } + if ref.Profile != "" { + sessionEnv[semconv.EnvProfile] = ref.Profile + } + + // Capture the session ID atomically at creation; a separate + // display-message round-trip would race with short-lived commands. + tmuxName := ref.tmuxName(opts.Type) + id, err := h.tmux.NewSessionWithEnv(tmuxName, path, sessionEnv, cmd) + if err != nil { + return Handle{}, fmt.Errorf("creating tmux session: %w", err) + } + + now := time.Now().UTC() + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionStatus, semconv.StatusRunning) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionStartedAt, now.Format(time.RFC3339)) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionCanonicalName, canonical) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionSessionType, string(opts.Type)) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionBranch, ref.Branch) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionProject, ref.Project) + if ref.Profile != "" { + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionProfile, ref.Profile) + } + + if err := hook.Trigger(semconv.HookPostSession, attrs, path); err != nil { + return Handle{}, fmt.Errorf("post-session hook: %w", err) + } + + return Handle{ + ID: id, + Ref: ref, + Type: opts.Type, + TmuxName: tmuxName, + Status: StatusRunning, + StartedAt: now, + }, nil +} + +// sessionCommand resolves the command and env a session runs with. A shell +// session runs $SHELL; an agent session runs its configured command. +func (h *Herd) sessionCommand(opts LaunchOpts) (cmd string, env map[string]string, err error) { + if opts.Type == SessionTypeShell { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + return shell, nil, nil + } + name := opts.Agent + if name == "" { + name = h.cfg.Defaults.Agent + } + if name == "" { + return "", nil, fmt.Errorf("no agent specified; use --agent or set defaults.agent in config") + } + agent, err := h.cfg.AgentByName(name) + if err != nil { + return "", nil, fmt.Errorf("resolving agent: %w", err) + } + return agent.Command(), agent.Env, nil +} + +// Resolve returns the live handle for a ref and type. +// Returns ErrSessionNotFound if no such session is running. +func (h *Herd) Resolve(ref Ref, t SessionType) (Handle, error) { + if t == "" { + t = SessionTypeAgent + } + all, err := h.handles() + if err != nil { + return Handle{}, err + } + canonical := ref.CanonicalName() + for _, hd := range all { + if hd.Ref.CanonicalName() == canonical && hd.Type == t { + return hd, nil + } + } + return Handle{}, fmt.Errorf("%w: %s (%s)", ErrSessionNotFound, canonical, t) +} + +// Sessions returns every live session belonging to the active profile. With +// no active profile, that is every session codeherd started. +func (h *Herd) Sessions() ([]Handle, error) { + all, err := h.handles() + if err != nil { + return nil, err + } + var out []Handle + for _, hd := range all { + if hd.Ref.Profile == h.profile { + out = append(out, hd) + } + } + return out, nil +} + +// StopSessions kills the sessions matching ref and returns the handles it +// stopped. Sessions are killed by tmux session ID, never by a rebuilt name. +// Stopping nothing is not an error — Teardown calls this unconditionally. +func (h *Herd) StopSessions(ref Ref, opts StopOpts) ([]Handle, error) { + all, err := h.handles() + if err != nil { + return nil, err + } + if opts.Type == "" && !opts.All { + opts.Type = SessionTypeAgent + } + + canonical := ref.CanonicalName() + var stopped []Handle + for _, hd := range all { + if hd.Ref.CanonicalName() != canonical { + continue + } + if !opts.All && hd.Type != opts.Type { + continue + } + if err := h.tmux.KillSession(hd.ID); err != nil { + return stopped, fmt.Errorf("killing session %s: %w", hd.Ref.CanonicalName(), err) + } + stopped = append(stopped, hd) + } + return stopped, nil +} + +// SetStatus transitions an agent session's status and annotation, addressing +// it by canonical name. +// +// This is the one operation that does not take a Ref, and it is deliberate: +// `ch plugin handle-claude` receives a bare name from $CODEHERD_SESSION and +// cannot recover a Ref from it — the profile prefix is ambiguous, since +// work-myapp-feat could be profile "work" + project "myapp", or a project +// literally named "work-myapp". One narrow escape hatch beats re-exporting +// name resolution. +// +// Errors are suppressed: a hook must never fail the agent it is reporting on. +func (h *Herd) SetStatus(canonicalName string, status Status, annotation string) error { + if canonicalName == "" { + return nil + } + if status != StatusRunning && status != StatusWaiting { + return nil + } + + records, _ := h.tmux.ListSessions() + actualName := "" + for _, r := range records { + if r.CanonicalName == canonicalName && SessionType(r.SessionType) == SessionTypeAgent { + actualName = r.Name + break + } + } + if actualName == "" { + return nil // session not found — suppress + } + + _ = h.tmux.SetOption(actualName, semconv.TmuxOptionStatus, string(status)) + _ = h.tmux.SetOption(actualName, semconv.TmuxOptionAnnotation, annotation) + + hasPrefix := strings.HasPrefix(actualName, semconv.StatusPrefix) + if status == StatusRunning && hasPrefix { + _ = h.tmux.RenameSession(actualName, strings.TrimPrefix(actualName, semconv.StatusPrefix)) + } else if status != StatusRunning && !hasPrefix { + _ = h.tmux.RenameSession(actualName, semconv.StatusPrefix+actualName) + } + return nil +} + +// handles lists every codeherd session tmux knows about, across all profiles. +// It is the single place a tmux record becomes a Handle — the six copies of +// this loop are what let Show and Stop disagree about identity. +func (h *Herd) handles() ([]Handle, error) { + records, err := h.tmux.ListSessions() + if err != nil { + return nil, fmt.Errorf("listing tmux sessions: %w", err) + } + out := make([]Handle, 0, len(records)) + for _, r := range records { + if r.CanonicalName == "" { + continue // not a codeherd session + } + out = append(out, handleFrom(r)) + } + return out, nil +} + +func handleFrom(r tmux.SessionRecord) Handle { + hd := Handle{ + ID: r.ID, + Ref: Ref{Profile: r.Profile, Project: r.Project, Branch: r.Branch}, + Type: SessionType(r.SessionType), + TmuxName: r.Name, + Status: Status(r.Status), + + Annotation: r.Annotation, + } + if r.StartedAt != "" { + hd.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) + } + return hd +} + +``` + +Add `"errors"` to the import block. + +**One behaviour note for the reviewer.** `Handle.Ref` is rebuilt from `@codeherd_branch`, which stores the raw identity branch, and `Ref.CanonicalName()` re-flattens it — so `Resolve`'s comparison is against a re-derived canonical name, not the stored `@codeherd_canonical_name`. These agree for every session `Launch` created. If they ever disagree, the stored name is authoritative; prefer `r.CanonicalName` in the match if a test surfaces a mismatch, and record it in spec §14.1. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/herd/... -v -run 'Launch|Resolve|Sessions|StopSessions|SetStatus|Start'` +Expected: PASS, including the moved `TestStart_TriggersHooks` and the five new tests from Step 2. + +- [ ] **Step 6: Migrate the session callers** + +`cmd/services.go`: delete `newSessionService` (24-26), `showSessionForProfile` (50-64), `stopSessionForProfile` (68-80), `listSessionsForProfile` (84-100). The file is now just `newWorktreeService` — leave it; Task 5 empties it. + +`cmd/session.go`: +- `ListSessionCmd.Run`: `sessions, err := h.Sessions()`; print `s.Ref.CanonicalName()`, `s.Type`, `s.Status`. +- `ShowSessionCmd.Run`: `info, err := h.Resolve(h.Ref(project, branch), c.sessionType())`; fields become `info.Ref.CanonicalName()`, `info.Type`, `info.Status`, `info.Annotation`, `info.StartedAt`. +- `CreateSessionCmd.Run` (160-285): the agent/shell resolution block (165-189), the `hooks.New` + `worktree.NewService` block (191-194), and the `session.NewService` + `Start` block (254-266) all collapse. The worktree-create-if-missing block (196-240) **stays as-is** until Task 5 — it still uses `wtSvc`. The tail becomes: + ```go + handle, err := h.Launch(h.Ref(project, branch), herd.LaunchOpts{ + Type: c.sessionType(), + Agent: flagAgent, // "" means defaults.agent — resolved inside Launch + Attach: c.Attach, + }) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout()) + return sessionErr(cmd, err) + } + fmt.Fprintln(cmd.OutOrStdout(), "done") + if !c.Attach { + shellSuffix := "" + if c.Shell { + shellSuffix = " --shell" + } + fmt.Fprintf(cmd.OutOrStdout(), "Attach with: ch attach session %s %s%s\n", project, branch, shellSuffix) + return nil + } + return execTmuxAttach(handle.ID) + ``` + Delete `resolveAgentName` (27-35) — `Launch` owns that fallback now. Its one other caller is `cmd/worktree.go:170`, handled below. +- `DeleteSessionCmd.Run`: the confirm probe becomes `h.Resolve(…)`; the stop becomes: + ```go + if _, err := h.StopSessions(h.Ref(project, branch), herd.StopOpts{Type: sessionType}); err != nil { + fmt.Fprintln(cmd.OutOrStdout()) + return sessionErr(cmd, err) + } + ``` + `StopSessions` no longer errors when nothing is running, so the "not found" message now comes from the `Resolve` probe. With `--force` and no session, the command succeeds silently. That is a deliberate behaviour change: stopping an already-stopped session is not a failure. Note it in spec §14.1. +- `AttachSessionCmd.Run`: `info, err := h.Resolve(h.Ref(project, branch), sessionType)` then `execTmuxAttach(info.ID)`. +- `sessionTypeFromFlag` (371-376) returns `herd.SessionType`. Make it a method for symmetry: each command struct with a `Shell` field gets `func (c *XCmd) sessionType() herd.SessionType`. Or keep the free function returning `herd.SessionType` — either is fine; be consistent. + +`cmd/worktree.go:165-199` (the `--attach` tail): delete the `resolveAgentName` + `cfg.AgentByName` + `session.NewService` + `Start` block. It becomes: +```go + if c.Attach { + flagAgent := "" + if cmd.Flags().Changed("agent") { + flagAgent = c.Agent + } + ref := h.Ref(project, branch) + fmt.Fprintf(cmd.OutOrStdout(), "Starting session %s... ", ref.CanonicalName()) + handle, err := h.Launch(ref, herd.LaunchOpts{Agent: flagAgent, Attach: true}) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout()) + return sessionErr(cmd, err) + } + fmt.Fprintln(cmd.OutOrStdout(), "done") + return execTmuxAttach(handle.ID) + } +``` +This silently fixes spec §3.1's second row for the `--attach` path: `semconv.SessionName("", project, branch)` at line 179 becomes `ref.CanonicalName()`, which carries the profile. The `Start` call at 183-191 also never passed `Profile` at all — under a profile it created an *unprefixed* session. Both die here. + +`cmd/plugin.go:47-63`: delete the `tmux.NewClient` + `session.NewService` lines. `sesSvc.SetStatus(sessionName, semconv.StatusRunning, "")` → `h.SetStatus(sessionName, herd.StatusRunning, "")`, and likewise for the two `StatusWaiting` calls. Drop the `hooks`, `session`, and `tmux` imports. + +**Watch out:** `plugin handle-claude` runs under `PersistentPreRunE`, so `h` is built. But `pluginCmd` is added directly to `rootCmd` in `init()` (line 79) — confirm `PersistentPreRunE` still runs for it. `cmd/plugin_test.go` covers this path; if `h` is nil there, the test will panic and tell you. + +`cmd/errors.go`: `sessionErr` now matches `herd.ErrSessionExists` / `herd.ErrSessionNotFound` / `herd.ErrPathNotFound` / `herd.ErrNotCloned` / `herd.ErrWorktreeNotFound`. `*session.SessionExistsError` → `*herd.SessionExistsError`; its `sesErr.Project` / `.Branch` become `sesErr.Ref.Project` / `sesErr.Ref.Branch`. Leave `worktreeErr` alone and leave the `os.Exit(1)` alone — collapsing the two translators and fixing the exit is Plan 2's job (spec §12), and doing it here would change exit codes mid-collapse. + +`cmd/tui.go:121`: delete `sesSvc := newSessionService()` and drop it from the `tui.NewModel` call. + +`internal/tui/model.go`: delete the `sesSvc *session.Service` field (65). It is **assigned and never read** (spec §3.2) — deleting it is free. Drop the `session` import. Update `NewModel`'s signature and `cmd/tui.go`'s call. + +`internal/tui/actions.go` — four `session.NewService(...)` + `Start(...)` sites (64-80, 121-134, 233-246, 417-430). Each becomes: +```go + handle, err := hrd.Launch(hrd.Ref(project, branch), herd.LaunchOpts{Agent: agentName}) + if err != nil { + return errMsg{err: err} + } + return attachMsg{session: handle.ID} +``` +with `Type: herd.SessionTypeShell` for the `shellAction` site (233). The captured `profile` variable (`m.activeProfile()`) disappears from all four — the Ref carries it. `hrd` is `m.herd`, captured in the enclosing method exactly like `cfg` is today. + +Note what this fixes for free: `shellAction` and `startSessionAfterCreate` pass `Branch: branch` where `branch` came from `sel.Branch` — the **display** branch. `hrd.Ref(project, branch)` has the same problem until Task 5 gives the TUI `Workspace.Ref`. Do not chase it here; Task 5 closes it. + +`internal/tui/agent_picker.go:113-127`: same transformation; `pending.tmuxClient` and `pending.profile` are no longer needed for the Launch, but leave the fields — `pending` still carries `tmuxClient` for the worktree call until Task 5. + +`internal/tui/delete_teardown_test.go`: the two regression tests build `Model{sesSvc: …, wtSvc: …}`. Drop `sesSvc` (the field is gone). Keep both tests passing against the current `confirmDeleteAll` — they are Task 5's target, not this one's. + +`cmd/services_test.go`: it tests `showSessionForProfile` / `stopSessionForProfile` / `listSessionsForProfile`, all deleted. **Delete the file.** Its coverage moves to `internal/herd/session_test.go`'s profile tests — that is the same assertion, one layer down, where it belongs. + +`cmd/session_internal_test.go`: it overrides `newSessionService` (a `var`), which no longer exists. Replace the seam: tests assign `h = herd.New(cfg, registry, herd.Deps{Tmux: fakeRunner, Git: fakeGit})` directly, since `h` is a package var in `cmd`. Read the file first — if a test depends on `newSessionService` returning a specific mock, the equivalent is a `herd.Deps{Tmux: …}` with the same fake. + +- [ ] **Step 7: Delete `internal/session`** + +```bash +git rm -r internal/session +``` + +Run: `go build ./... && go vet ./...` and `git grep -n 'internal/session'` +Expected: clean build; grep returns nothing. + +- [ ] **Step 8: Verify** + +Run: `make check` +Expected: green, ≥80%. + +The integration tests are the ones to watch here: `cmd/session_integration_test.go` and `cmd/profiles_integration_test.go` drive real tmux. `TestProfiles_sessionIsolationAcrossProfiles` (`profiles_integration_test.go:136`) covers profile × {create, list} — it must still pass unchanged. If it fails, `Launch` is not stamping the profile the way `Start` did. + +- [ ] **Step 9: Record and commit** + +Append to spec §14.1 under `**Task 4 — session domain**`. Four things this task's own steps flagged as recordable — go back and collect them rather than trusting memory: + +> Sessions now stamp `@codeherd_project` (new tmux option + `SessionRecord.Project`), so a `Handle` from a list carries a complete `Ref`. Sessions started before the upgrade have `Ref.Project == ""` and fail loudly in `Teardown` rather than deleting the wrong thing. +> +> **Behaviour change:** `ch delete session --force` no longer errors when nothing is running. `StopSessions` treats stopping nothing as success because `Teardown` calls it unconditionally. +> +> **Behaviour change:** `ch create worktree --attach` now starts a profile-prefixed session. It never did — `cmd/worktree.go:183-191` passed no `Profile` at all, so under a profile it created an unprefixed session that nothing else could address. +> +> `cmd/services_test.go` was deleted outright; it tested the three `*ForProfile` shims. Its coverage moved down a layer into `internal/herd/session_test.go`'s profile tests. + +Also record: whether `Resolve` matching on a re-derived `CanonicalName()` rather than the stored `@codeherd_canonical_name` caused any mismatch (Step 4's note), and whether `PersistentPreRunE` actually runs for the `plugin` command (Step 6's warning — if `h` was nil there, say so; Plan 2 needs to know). + +```bash +git add -A +git commit -m "refactor: fold session into internal/herd + +session.Service was the only core service without config, so it could not +know the active profile, so Show and Stop took no profile parameter and +addressed sessions by a name rebuilt without one. You could create a +session you could not address. + +Launch/Resolve/StopSessions all key on a Ref that carries the profile. +The six copies of the list-and-match loop collapse to one; ShowByName and +StopByName — the escape hatch for the missing parameter — are gone, and +so are cmd/services.go's three dispatch shims. + +Sessions now stamp @codeherd_project, so a Handle from a list carries a +complete Ref. Model.sesSvc is deleted: it was assigned and never read." +``` + +--- + +### Task 5: worktree domain → `herd` — the defect dies + +The last domain and the one that was already broken: `internal/worktree` imports `internal/tmux` and manages sessions with it (spec §2.2), without the profile, and therefore wrongly. Here `Teardown` sits beside the session code and calls it directly with the profile in hand. + +**Files:** +- Create: `internal/herd/workspace.go` +- Create: `internal/herd/workspace_test.go` (moved from `internal/worktree/worktree_test.go`) +- Create: `internal/herd/integration_test.go` (moved from `internal/worktree/integration_test.go`) +- Modify: `cmd/services.go` (delete `newWorktreeService`; the file is now empty — delete it) +- Modify: `cmd/worktree.go`, `cmd/session.go`, `cmd/template.go`, `cmd/completion.go`, `cmd/tui.go` +- Modify: `internal/tui/model.go`, `actions.go`, `form.go`, `agent_picker.go`, `remote_picker.go`, `items.go` +- Modify: `internal/tui/delete_teardown_test.go` +- Delete: `internal/worktree/` (all files) + +**Interfaces:** +- Consumes: everything from Tasks 2-4, especially `h.StopSessions`, `h.Clone`, `h.worktreePath`, `h.cloneDir`, `h.worktreesRoot`, `git.ParseRef`. +- Produces: + ```go + type Workspace struct { + Ref Ref // identity — feed this back into any operation + Path string + IsMain bool + DisplayBranch string // for rendering only; never an input + HeadHint string // "detached" | "on " | "" + Agent, Shell *Handle // nil when not running + } + + type EnsureOpts struct { + AutoClone bool + Provision bool + StartPoint string // --from + Track string // --track: "[/]" + } + + type TeardownOpts struct{ Force bool } + + func (h *Herd) EnsureWorkspace(ref Ref, opts EnsureOpts) (Workspace, error) + func (h *Herd) Provision(ref Ref) error + func (h *Herd) List(project string) ([]Workspace, error) // "" = all projects + func (h *Herd) Teardown(ref Ref, opts TeardownOpts) error + func (h *Herd) RemoteBranches(project string, fetch bool) ([]RemoteBranch, error) + ``` + +Note `RemoteBranches` takes a `fetch` bool rather than being two methods: `worktree.ListRemoteBranches` (completion, no fetch) and `worktree.RemoteBranches` (TUI picker, fetches first) differed by exactly one best-effort `FetchAll` line. One method, one named argument. + +`EnsureOpts.Track` and `.StartPoint` are mutually exclusive; `EnsureWorkspace` returns an error if both are set. `cmd/worktree.go` already enforces this via `MarkFlagsMutuallyExclusive`, but the TUI form does not. + +- [ ] **Step 1: Move the worktree tests** + +Move `internal/worktree/worktree_test.go` → `internal/herd/workspace_test.go` and `internal/worktree/integration_test.go` → `internal/herd/integration_test.go`. Transform: +- line 1: `package worktree` → `package herd` +- Delete `mockHook` (16-20), `hookCall` (20-26), `mockGit` (243), `mockTmuxRunner` (319), `mockTmuxRunnerWithError` (329), `mockTmuxRunnerKillFails` (1056), `mockTmuxRunnerPerSession` (1071) — `fakes_test.go` supplies all of it. The four tmux mocks collapse into `fakeTmux` with `RunFn` or `Sessions` overrides; that collapse is the point of the shared fake. +- Delete the parser tests already moved to `internal/git` in Task 1 (they should already be gone — verify with `git grep -n 'parseWorktreePorcelain\|ParseRef' internal/herd/`). +- `makeService(t, git, tmuxRunner) (*Service, string)` → `workspaceHerd(t, g *fakeGit, f *fakeTmux) (*Herd, string)`, returning `New(cfg, nil, Deps{Tmux: f, Git: g})` with the same tmpDir + myapp config. Keep `cloneDirPath(tmpDir)` unchanged. +- `svc.New(p, b)` → `h.EnsureWorkspace(h.Ref(p, b), EnsureOpts{})` +- `svc.NewFrom(p, b, from)` → `h.EnsureWorkspace(h.Ref(p, b), EnsureOpts{StartPoint: from})` +- `svc.NewTracking(p, b, ref)` → `h.EnsureWorkspace(h.Ref(p, b), EnsureOpts{Track: ref})`. **Careful:** `NewTracking` derives the local branch from the remote ref when `branch` is empty, so the *result's* Ref may differ from the input Ref. `EnsureWorkspace` returns `Workspace`, and `Workspace.Ref` is authoritative — assert on that, not on the input. +- `svc.Delete(DeleteRequest{p, b, force})` → `h.Teardown(h.Ref(p, b), TeardownOpts{Force: force})` +- `svc.List(p)` → `h.List(p)`; entries are `Workspace` now, so `e.Session == "myapp-feat (running)"` becomes `e.Agent != nil`. +- `svc.WorktreePath(p, b)` → the unexported `h.worktreePath` plus an existence check; the tests that covered it fold into `EnsureWorkspace` / `Launch` coverage. If a test only asserted path derivation, it is already covered by `paths_test.go` — delete it rather than duplicating. +- `ErrNotCloned` etc. resolve locally now (same package). + +Add the tests that make the shipped defect structurally impossible: + +```go +// The defect, stated as a test. A worktree deleted under an active profile +// must take its sessions with it. worktree.Delete rebuilt the names with +// SessionName("", …), searched for myapp-feat, missed work-myapp-feat, and +// force-deleted the worktree anyway — leaving the agent process alive against +// a directory that no longer existed. +func TestTeardown_underProfile_killsSessionsThenDeletesWorktree(t *testing.T) { + g := &fakeGit{} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Profile: "work", Branch: "feat", Project: "myapp"}, + {ID: "$2", Name: "work-myapp-feat~sh", Canonical: "work-myapp-feat", + Type: "shell", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: g}) + + wtPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat") + if err := os.MkdirAll(wtPath, 0o755); err != nil { + t.Fatal(err) + } + + if err := h.Teardown(h.Ref("myapp", "feat"), TeardownOpts{Force: true}); err != nil { + t.Fatalf("Teardown: %v", err) + } + + killed := f.killed() + sort.Strings(killed) + if len(killed) != 2 || killed[0] != "$1" || killed[1] != "$2" { + t.Errorf("killed = %v, want [$1 $2]; a missed kill orphans the agent process", killed) + } + if !g.called("Remove", wtPath) { + t.Errorf("worktree was not removed; calls=%v", g.Calls) + } +} + +// Without --force, a running session blocks the delete rather than being +// killed under the user. +func TestTeardown_runningSessionWithoutForce(t *testing.T) { + g := &fakeGit{} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feat", Canonical: "myapp-feat", + Type: "agent", Branch: "feat", Project: "myapp"}, + }} + h, dir := workspaceHerd(t, g, f) + if err := os.MkdirAll(filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat"), 0o755); err != nil { + t.Fatal(err) + } + + err := h.Teardown(h.Ref("myapp", "feat"), TeardownOpts{}) + if !errors.Is(err, ErrSessionRunning) { + t.Fatalf("err = %v, want ErrSessionRunning", err) + } + if len(f.killed()) != 0 { + t.Errorf("killed %v without --force", f.killed()) + } + if g.called("Remove") { + t.Error("worktree was removed despite a running session") + } +} + +// List joins worktrees to sessions on the Ref, so the join is profile-correct. +// worktree.Service.List hardcoded SessionName("", …) at line 593, which is why +// `ch list worktree`'s "(running)" marker never appeared under a profile. +func TestList_underProfile_findsRunningSession(t *testing.T) { + dir := t.TempDir() + cloneDir := filepath.Join(dir, "github.com", "user", "myapp") + wtPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } + + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{{Path: wtPath, Branch: "feat"}}, nil + }} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: g}) + + spaces, err := h.List("") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(spaces) != 1 { + t.Fatalf("got %d workspaces, want 1", len(spaces)) + } + if spaces[0].Agent == nil { + t.Fatal("running agent session not joined to its workspace under a profile") + } + if spaces[0].Agent.ID != "$1" { + t.Errorf("Agent.ID = %q, want $1", spaces[0].Agent.ID) + } +} + +// A diverged HEAD changes what we render, never what we address. This is the +// other half of the shipped defect: Item.Branch held the display branch and +// round-tripped into wtSvc.Delete. +func TestList_divergedHead_refKeepsIdentityBranch(t *testing.T) { + dir := t.TempDir() + cloneDir := filepath.Join(dir, "github.com", "user", "myapp") + wtPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } + + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + // The worktree was created for "feat" but HEAD now sits on "other". + return []git.WorktreeInfo{{Path: wtPath, Branch: "other"}}, nil + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, nil, Deps{Tmux: &fakeTmux{}, Git: g}) + + spaces, err := h.List("") + if err != nil { + t.Fatalf("List: %v", err) + } + if spaces[0].Ref.Branch != "feat" { + t.Errorf("Ref.Branch = %q, want %q — identity must survive divergence", spaces[0].Ref.Branch, "feat") + } + if spaces[0].DisplayBranch != "other" { + t.Errorf("DisplayBranch = %q, want %q", spaces[0].DisplayBranch, "other") + } + if spaces[0].HeadHint != "on other" { + t.Errorf("HeadHint = %q, want %q", spaces[0].HeadHint, "on other") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/herd/...` +Expected: FAIL — `undefined: Workspace`, `h.Teardown undefined`, `h.EnsureWorkspace undefined`. + +- [ ] **Step 3: Write `internal/herd/workspace.go`** + +`New` / `NewFrom` / `NewTracking` (`worktree.go:343-526`) are three near-identical 50-line methods differing only in which git call creates the worktree. They collapse into one `EnsureWorkspace` with a switch. `freshenStartPoint` (325-340) moves verbatim. `Provision` is new — it is the copy+template block that `cmd/worktree.go:133-160`, `cmd/session.go:207-236`, and `tui/actions.go:446-477` each spell out separately, and it is where spec §3.1's first row dies. + +```go +package herd + +import ( + "errors" + "fmt" + "os" + + "github.com/xico42/codeherd/internal/filecopy" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/herdtemplate" + "github.com/xico42/codeherd/internal/semconv" +) + +// Workspace is a worktree together with its sessions — the domain object the +// old split could not express. +type Workspace struct { + // Ref is identity. Feed it back into any operation. It survives a + // diverged HEAD, a profile switch, and a rename. + Ref Ref + Path string + IsMain bool // true for the main clone dir + + // DisplayBranch is what a front end should render: the branch HEAD is + // actually on. It is NOT identity and must never be fed back in — that + // round-trip is what orphaned an agent against a deleted worktree. + DisplayBranch string + + // HeadHint is "detached", "on ", or "" when HEAD agrees with Ref. + HeadHint string + + // Agent and Shell are nil when that session type is not running. + Agent *Handle + Shell *Handle +} + +// EnsureOpts configures workspace creation. The zero value creates the +// worktree from the project's default branch and provisions nothing. +type EnsureOpts struct { + AutoClone bool // clone the project first if it is not cloned + Provision bool // run file copy + .herd templates after creating + StartPoint string // --from: base the new branch on this ref + Track string // --track: "[/]"; derives the local name when Ref.Branch is "" +} + +// TeardownOpts configures workspace deletion. +type TeardownOpts struct { + Force bool // kill running sessions instead of refusing +} + +// EnsureWorkspace makes the workspace for ref exist: clone if asked, create +// the worktree if missing, provision if asked. It is idempotent on the clone +// but not on the worktree — an existing worktree returns ErrWorktreeExists. +// +// The returned Workspace.Ref is authoritative: with Track, the local branch +// is derived from the remote ref and may differ from the ref passed in. +func (h *Herd) EnsureWorkspace(ref Ref, opts EnsureOpts) (Workspace, error) { + if opts.StartPoint != "" && opts.Track != "" { + return Workspace{}, errors.New("cannot combine a start point with a tracking ref") + } + + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return Workspace{}, err + } + if opts.AutoClone { + // Already cloned is the normal case, not a failure. + if err := h.Clone(ref.Project); err != nil && !errors.Is(err, ErrAlreadyCloned) { + return Workspace{}, err + } + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return Workspace{}, fmt.Errorf("%w: %s", ErrNotCloned, ref.Project) + } + + // A tracking ref decides the local branch name, so resolve it before the + // ref is used for anything path-shaped. + remoteRef := "" + if opts.Track != "" { + remotes, _ := h.git.Remotes(cloneDir) + remote, remoteBranch, _ := git.ParseRef(remotes, opts.Track) + if ref.Branch == "" { + ref.Branch = remoteBranch + } + remoteRef = remote + "/" + remoteBranch + if has, _ := h.git.HasLocalBranch(cloneDir, ref.Branch); has { + return Workspace{}, fmt.Errorf("%w: %s", ErrLocalBranchExists, ref.Branch) + } + if err := h.git.Fetch(cloneDir, remote, remoteBranch); err != nil { + return Workspace{}, fmt.Errorf("fetching %s: %w", remoteRef, err) + } + } + + wtPath, err := h.worktreePath(ref) + if err != nil { + return Workspace{}, err + } + if _, err := os.Stat(wtPath); err == nil { + return Workspace{}, fmt.Errorf("%w: %s/%s", ErrWorktreeExists, ref.Project, ref.Branch) + } + + p := h.cfg.Projects[ref.Project] + hook := h.hookFor(ref.Project) + attrs := map[string]string{ + semconv.HookAttrProject: ref.Project, + semconv.HookAttrBranch: ref.Branch, + semconv.HookAttrRepo: p.Repo, + semconv.HookAttrCloneDir: cloneDir, + semconv.HookAttrWorktreePath: wtPath, + } + if err := hook.Trigger(semconv.HookPreWorktree, attrs, wtPath); err != nil { + return Workspace{}, fmt.Errorf("pre-worktree hook: %w", err) + } + + root, err := h.worktreesRoot(ref.Project) + if err != nil { + return Workspace{}, err + } + if err := os.MkdirAll(root, 0o755); err != nil { + return Workspace{}, fmt.Errorf("creating worktrees dir: %w", err) + } + + if err := h.addWorktree(ref, cloneDir, wtPath, remoteRef, opts); err != nil { + return Workspace{}, err + } + + if err := hook.Trigger(semconv.HookPostWorktree, attrs, wtPath); err != nil { + return Workspace{}, fmt.Errorf("post-worktree hook: %w", err) + } + + if opts.Provision { + if err := h.Provision(ref); err != nil { + return Workspace{}, err + } + } + + return Workspace{ + Ref: ref, + Path: wtPath, + IsMain: wtPath == cloneDir, + DisplayBranch: ref.Branch, + }, nil +} + +// addWorktree runs the git call that actually creates the worktree. The three +// shapes were three near-identical 50-line methods; only this switch differed. +func (h *Herd) addWorktree(ref Ref, cloneDir, wtPath, remoteRef string, opts EnsureOpts) error { + switch { + case opts.Track != "": + if err := h.git.AddTracking(cloneDir, wtPath, ref.Branch, remoteRef); err != nil { + return fmt.Errorf("creating tracking worktree for %s: %w", remoteRef, err) + } + return nil + + case opts.StartPoint != "": + startPoint := h.freshenStartPoint(cloneDir, opts.StartPoint) + if err := h.git.AddNewBranchFrom(cloneDir, wtPath, ref.Branch, startPoint); err != nil { + return fmt.Errorf("creating worktree from %s: %w", startPoint, err) + } + return nil + + default: + // Try checking out an existing branch; fall back to branching from + // the project's default. + addErr := h.git.Add(cloneDir, wtPath, ref.Branch) + if addErr == nil { + return nil + } + src := h.cfg.Projects[ref.Project].DefaultBranch + if src == "" { + src = "main" + } + startPoint := h.freshenStartPoint(cloneDir, src) + if err := h.git.AddNewBranchFrom(cloneDir, wtPath, ref.Branch, startPoint); err != nil { + return fmt.Errorf("failed to create worktree (add: %v; add -b from %s: %w)", addErr, startPoint, err) + } + return nil + } +} + +// freshenStartPoint fetches updates for the source ref and returns the start +// point a new branch should be based on. It prefers a fast-forwarded local +// branch (to preserve un-pushed commits), falling back to the remote-tracking +// ref, or the raw ref when the source is not on a remote (tags, SHAs, +// local-only branches). All git failures here are best-effort. +func (h *Herd) freshenStartPoint(cloneDir, src string) string { + remotes, _ := h.git.Remotes(cloneDir) + remote, branch, explicit := git.ParseRef(remotes, src) + if explicit { + _ = h.git.Fetch(cloneDir, remote, branch) + return src + } + if err := h.git.Fetch(cloneDir, "origin", src); err != nil { + return src + } + if has, _ := h.git.HasLocalBranch(cloneDir, src); has { + _ = h.git.FastForward(cloneDir, "origin", src) + return src + } + return "origin/" + src +} + +// Provision runs file copy and .herd template processing for a workspace. +// +// The template context is built from ref in one place, which is what kills +// the divergence where `ch create session` rendered a profile-qualified +// SessionName into a .herd file while `ch create worktree`, `ch template`, +// and the TUI rendered a profile-blind one — for the same worktree. +func (h *Herd) Provision(ref Ref) error { + wtPath, err := h.worktreePath(ref) + if err != nil { + return err + } + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return err + } + + p := h.cfg.Projects[ref.Project] + hook := h.hookFor(ref.Project) + attrs := map[string]string{ + semconv.HookAttrProject: ref.Project, + semconv.HookAttrBranch: ref.Branch, + semconv.HookAttrWorktreePath: wtPath, + } + + if len(p.Files) > 0 { + if err := filecopy.New(hook).Copy(p.Files, cloneDir, wtPath, attrs); err != nil { + return fmt.Errorf("copying files: %w", err) + } + } + + if _, err := herdtemplate.New(hook).Process(herdtemplate.ProcessContext{ + Project: ref.Project, + Branch: ref.Branch, + WorktreePath: wtPath, + SessionName: ref.CanonicalName(), + }, attrs); err != nil { + return fmt.Errorf("processing templates: %w", err) + } + return nil +} + +// List returns every workspace for a project, or for all projects when +// project is "". Projects that are not cloned, and projects whose git calls +// fail, are skipped rather than failing the whole listing. +// +// This is the one place worktrees and sessions are joined, and the join is on +// the Ref — which carries the profile. The old split computed identity in +// worktree.Service.List, threw it away into a display string, and made the +// TUI recompute it. +func (h *Herd) List(project string) ([]Workspace, error) { + names, err := h.projectNames(project) + if err != nil { + return nil, err + } + sessions, err := h.Sessions() + if err != nil { + return nil, err + } + byName := make(map[string][]Handle, len(sessions)) + for _, hd := range sessions { + key := hd.Ref.CanonicalName() + byName[key] = append(byName[key], hd) + } + + var out []Workspace + for _, name := range names { + cloneDir, err := h.cloneDir(name) + if err != nil { + continue + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + continue + } + infos, err := h.git.List(cloneDir) + if err != nil { + continue + } + defaultBranch := h.cfg.Projects[name].DefaultBranch + for _, wt := range infos { + ws := h.workspaceFrom(name, cloneDir, defaultBranch, wt) + for i := range byName[ws.Ref.CanonicalName()] { + hd := byName[ws.Ref.CanonicalName()][i] + switch hd.Type { + case SessionTypeAgent: + ws.Agent = &hd + case SessionTypeShell: + ws.Shell = &hd + } + } + out = append(out, ws) + } + } + return out, nil +} + +// workspaceFrom derives identity and display from one git worktree entry. +func (h *Herd) workspaceFrom(project, cloneDir, defaultBranch string, wt git.WorktreeInfo) Workspace { + identity := semconv.WorktreeIdentityBranch(wt.Path, cloneDir, defaultBranch, wt.Branch) + ws := Workspace{ + Ref: h.Ref(project, identity), + Path: wt.Path, + IsMain: wt.Path == cloneDir, + DisplayBranch: wt.Branch, + } + switch { + case wt.Detached: + ws.HeadHint = "detached" + case wt.Branch != "" && semconv.FlattenBranch(wt.Branch) != semconv.FlattenBranch(identity): + ws.HeadHint = "on " + wt.Branch + } + return ws +} + +// Teardown stops a workspace's sessions and deletes its worktree. +// +// The order is not incidental. The TUI killed sessions by ID and then called +// worktree.Delete, which ran a second, profile-blind kill loop that either +// missed or no-opped — and force-deleted the worktree either way, orphaning +// the agent process. One loop, keyed on a Ref that carries the profile. +func (h *Herd) Teardown(ref Ref, opts TeardownOpts) error { + wtPath, err := h.worktreePath(ref) + if err != nil { + return err + } + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + return fmt.Errorf("%w: %s/%s", ErrWorktreeNotFound, ref.Project, ref.Branch) + } + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return err + } + + if !opts.Force { + running, err := h.handles() + if err != nil { + return err + } + canonical := ref.CanonicalName() + for _, hd := range running { + if hd.Ref.CanonicalName() == canonical { + return fmt.Errorf("%w: %s (%s)", ErrSessionRunning, canonical, hd.Type) + } + } + } + + if _, err := h.StopSessions(ref, StopOpts{All: true}); err != nil { + return err + } + if err := h.git.Remove(cloneDir, wtPath); err != nil { + return fmt.Errorf("removing worktree: %w", err) + } + return nil +} + +// RemoteBranches returns a project's remote-tracking branches. When fetch is +// true it refreshes all remotes first (best-effort) so the list reflects +// current remote state; completion passes false to stay fast. +func (h *Herd) RemoteBranches(project string, fetch bool) ([]RemoteBranch, error) { + cloneDir, err := h.cloneDir(project) + if err != nil { + return nil, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + if fetch { + _ = h.git.FetchAll(cloneDir) + } + branches, err := h.git.ListRemoteBranches(cloneDir) + if err != nil { + return nil, fmt.Errorf("listing remote branches: %w", err) + } + return branches, nil +} +``` + +Note the loop-variable capture in `List`: `hd := byName[…][i]` takes a fresh copy per iteration before `&hd` is stored. Go 1.22+ scopes range variables per-iteration, but this indexes explicitly to make the copy obvious to a reviewer. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/herd/... -v` +Expected: PASS, including all four new tests from Step 1 and every moved worktree test. + +`TestTeardown_underProfile_killsSessionsThenDeletesWorktree` passing is the moment the shipped defect is dead structurally — roughly two thirds of the way through Plan 1, exactly as spec §12.1 predicted. + +- [ ] **Step 5: Migrate the worktree callers** + +`cmd/services.go`: delete `newWorktreeService`. The file is now empty — `git rm cmd/services.go`. + +`cmd/worktree.go`: +- `ListWorktreeCmd.Run`: `entries, err := h.List(project)`. Rendering changes: `e.Project` → `ws.Ref.Project`; `e.Branch` → `ws.DisplayBranch` (fall back to `"(detached)"` when empty, as today); `e.Path` → `ws.Path`; the `SESSION` column was `" (running)"` or `"--"` and becomes: + ```go + sess := "--" + if ws.Agent != nil { + sess = ws.Agent.Ref.CanonicalName() + " (running)" + } + ``` + This is spec §3.1's second row fixed: the marker now appears under a profile. +- `CreateWorktreeCmd.Run`: the whole 112-160 block becomes one call: + ```go + ws, err := h.EnsureWorkspace(h.Ref(project, posBranch), herd.EnsureOpts{ + AutoClone: false, // the CLI never auto-clones — previously implicit, now stated + Provision: true, + StartPoint: c.From, + Track: c.Track, + }) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout()) + return worktreeErr(cmd, project, posBranch, err) + } + ``` + Keep the three progress messages by branching on `c.Track != ""` / `c.From != ""` before the call, as today. The `--attach` tail from Task 4 now uses `ws.Ref` rather than `h.Ref(project, branch)` — `Track` may have derived a different local branch, and `ws.Ref` is authoritative. +- `DeleteWorktreeCmd.Run`: `h.Teardown(h.Ref(project, branch), herd.TeardownOpts{Force: c.Force})`. +- Drop the `config`, `filecopy`, `herdtemplate`, `hooks`, `semconv`, `session`, `tmux`, `worktree`, and `path/filepath` imports. + +`cmd/session.go`, `CreateSessionCmd.Run`: the worktree-create-if-missing block (196-240) collapses to: +```go + ref := h.Ref(project, branch) + if _, err := h.EnsureWorkspace(ref, herd.EnsureOpts{Provision: true}); err != nil { + if !errors.Is(err, herd.ErrWorktreeExists) { + return worktreeErr(cmd, project, branch, err) + } + // Already there — that is the common case for `create session`. + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Worktree %s/%s not found, creating... done\n", project, branch) + } +``` +Note the inversion: today the command probes `WorktreePath` and creates on `ErrWorktreeNotFound`; now it ensures and tolerates `ErrWorktreeExists`. Same outcome, one call. Drop the `config`, `filecopy`, `herdtemplate`, `hooks`, `filepath` imports. + +`cmd/template.go:71-89`: `semconv.SessionName("", project, branch)` at line 83 is spec §3.1's first row. Replace the hand-built `ProcessContext` with `h.Provision(h.Ref(project, branch))` — **except** `ch template` supports `--dry-run` and an arbitrary `[dir]`, which `Provision` does not. Two options; take the second: +1. Add `ProvisionOpts{DryRun bool, Dir string}` — but `Dir` breaks `Provision`'s premise that paths derive from the Ref. +2. **Leave `cmd/template.go` calling `herdtemplate` directly, and change only line 83 to `h.Ref(project, branch).CanonicalName()`.** `ch template` is a one-off operation on a directory, not a worktree operation; it is the one caller that legitimately does not go through `herd`. The one-line fix kills the divergence. + +Record this in spec §14.1: `Provision` does not subsume `ch template`, and `ch template`'s dry-run/arbitrary-dir shape is why. + +`cmd/completion.go:69-70,109-110`: both `completionBranchLister` and `completionRemoteBrancher` are `var`s (overridden in tests). They become: +```go +var completionBranchLister = func(project string) ([]herd.Workspace, error) { + return h.List(project) +} + +var completionRemoteBrancher = func(project string) ([]herd.RemoteBranch, error) { + return h.RemoteBranches(project, false) // no fetch — completion must stay fast +} +``` +The `cfg *config.Config` parameter goes away (`h` is a package var). `branchNames(entries []worktree.ListEntry)` (90) takes `[]herd.Workspace` and reads `ws.Ref.Branch` — identity, which is what a user should be completing, not the display branch. Update `cmd/completion_internal_test.go`'s overrides to the new signatures. + +`cmd/tui.go:119-125`: +```go +func runTUIDirect(tmuxClient *tmux.Client) error { + insideTmux := os.Getenv("TMUX") != "" + m := tui.NewModel(h, tmuxClient, insideTmux) + … +} +``` +`cfg`, `wtSvc`, and `registry` all come off `h` now (`h.Config()`, `h.Profile()`, `h.Profiles()`). Drop the `hooks` and `worktree` imports. + +`internal/tui/model.go`: +- `Model`: delete `wtSvc`; keep `cfg` (many render paths read it) but source it from `m.herd.Config()`. Delete `registry` and `profileCache` — `Model.herd` plus `herd.Profiles()` / `herd.Profile()` replaces both. `switchProfile` becomes: + ```go + func (m Model) switchProfile(direction int) (Model, tea.Cmd) { + names := m.herd.Profiles() + if len(names) < 2 { + return m, nil + } + idx := indexOf(names, m.herd.Profile()) + if idx < 0 { + return m, nil + } + n := len(names) + next := names[((idx+direction)%n+n)%n] + + nextHerd, err := m.herd.WithProfile(next) + if err != nil { + m.statusMsg = fmt.Sprintf("profile switch failed: %v", err) + return m, nil + } + m.herd = nextHerd + m.cfg = nextHerd.Config() + m = m.syncProfileKeyEnabled() + m.statusMsg = "Switched to profile " + next + return m, m.refreshCmd() + } + ``` + The `profileCache` existed to avoid re-reading a profile TOML on every switch. `WithProfile` re-reads it. That is one small file read per keypress — acceptable, and it deletes the cache plus the shared-registry mutation and its race commentary (`model.go:621-624`). If a reviewer objects, the cache belongs on `Herd`, not on the TUI. `syncProfileKeyEnabled` reads `len(m.herd.Profiles()) > 1`. +- `refreshCmd` (462-559): ~100 lines collapse to: + ```go + func (m Model) refreshCmd() tea.Cmd { + hrd := m.herd + return func() tea.Msg { + spaces, err := hrd.List("") + if err != nil { + return errMsg{err: err} + } + return itemsMsg(buildItems(hrd, spaces)) + } + } + ``` + The profile-snapshot comment (466-474) goes away with the registry mutation it guarded: `hrd` is an immutable value captured by pointer, and `WithProfile` returns a new one rather than mutating in place. That race is now structurally impossible — say so in the commit. +- `remoteBranchesMsg.branches` is `[]herd.RemoteBranch` (an alias for `git.RemoteBranch`, so the TUI need not import `git`). +- `fetchRemoteBranchesCmd` (654-662): `hrd.RemoteBranches(project, true)`. +- `showTrackForm` (690): `rb worktree.RemoteBranch` → `herd.RemoteBranch`. + +`internal/tui/items.go`: `buildItems(data refreshResult)` → `buildItems(hrd *herd.Herd, spaces []herd.Workspace) []list.Item`. Delete `refreshResult`, `wtEntry`, `agentInfo`, `projEntry` — every one of them existed to carry data `Workspace` now carries. The identity derivation (85-108) — `WorktreeIdentityBranch`, `SessionName`, the divergence switch, the display fallbacks — **all deletes**; `herd.List` did it. `Item` gains a `Ref herd.Ref` field and keeps `Branch` for rendering: +```go + item := Item{ + Ref: ws.Ref, + Project: ws.Ref.Project, + Branch: ws.DisplayBranch, + Path: ws.Path, + IsMain: ws.IsMain, + HeadHint: ws.HeadHint, + HasShell: ws.Shell != nil, + } + if ws.Shell != nil { + item.ShellSessionID = ws.Shell.ID + } + if ws.Agent != nil { + item.Group = groupAgent + item.HasAgent = true + item.AgentStatus = string(ws.Agent.Status) + item.AgentSessionID = ws.Agent.ID + item.Annotation = ws.Agent.Annotation + } else { + item.Group = groupWorktree + } +``` +The project rows (134-143) need the uncloned-project list, which `List` does not return (it skips uncloned projects). Get it from `hrd.Projects()` + `hrd.Project(name)` for `Cloned`, inside `refreshCmd`, and pass it to `buildItems` as a second argument. Keep `buildItems`'s sort (145-161) verbatim. + +`internal/tui/actions.go`: +- `confirmDeleteAll` (320-357) — the function this whole plan started from: + ```go + func (m Model) confirmDeleteAll() (tea.Model, tea.Cmd) { + ref := m.confirm.target.Ref // identity from herd.List — never a display string + hrd := m.herd + m.confirm, m.screen = nil, screenList + + return m, func() tea.Msg { + if err := hrd.Teardown(ref, herd.TeardownOpts{Force: true}); err != nil { + return errMsg{err: err} + } + return m.refreshCmd()() + } + } + ``` + The kill-by-ID loop and its five-line comment go away — not because the hazard stopped mattering, but because `Teardown` is the only path and it kills by ID. Delete the comment with the code. +- `confirmDeleteAgent` / `confirmDeleteShell` (359-389): `hrd.StopSessions(ref, herd.StopOpts{Type: herd.SessionTypeAgent})` and `…SessionTypeShell`. They currently swallow the error with `_ =`; return `errMsg` on failure now that there is a real error to report. +- `attachAction`, `shellAction`, `startSessionAfterCreate`, `agent_picker.submit`: each does clone → worktree → copy → template → session by hand. Each becomes: + ```go + if _, err := hrd.EnsureWorkspace(ref, herd.EnsureOpts{AutoClone: true, Provision: true}); err != nil && !errors.Is(err, herd.ErrWorktreeExists) { + return errMsg{err: err} + } + handle, err := hrd.Launch(ref, herd.LaunchOpts{Agent: agentName}) + if err != nil { + return errMsg{err: err} + } + return attachMsg{session: handle.ID} + ``` + `AutoClone: true` is spec §7.1's first row made explicit: the TUI auto-clones on attach and the CLI does not, and that is now an argument rather than a difference in which lines someone happened to write. + Use `sel.Ref` for worktree/agent rows. For `groupProject` rows there is no `Ref` yet — mint one with `hrd.Ref(project, defaultBranch)` exactly as today. +- Delete `runFileCopyAndTemplate` (446-477) and `projectCloneDir` (437-443). `Provision` owns both. `runFileCopyAndTemplate`'s `semconv.SessionName("", proj, branch)` at line 471 is spec §3.1's first row, deleted rather than fixed. +- Drop the `filecopy`, `herdtemplate`, `hooks`, `semconv`, `session`, `worktree`, `projectpkg`, `filepath` imports. + +`internal/tui/form.go:137-160`: +```go + return func() tea.Msg { + ws, err := hrd.EnsureWorkspace(hrd.Ref(project, branch), herd.EnsureOpts{ + AutoClone: true, + StartPoint: baseBranch, + Track: tracksRef, + }) + if err != nil { + return errMsg{err: err} + } + return worktreeCreatedMsg{ref: ws.Ref, path: ws.Path, attach: attach, agent: agent} + } +``` +`worktreeCreatedMsg` (`model.go:42-48`) swaps its `project` + `branch` strings for a `ref herd.Ref`. `Provision` stays false here: `startSessionAfterCreate` provisions, and doing it in both would run the templates twice. Confirm against `model.go:283-286` — if `attach` is false, nothing provisions, which is **a bug that exists today** (`form.submit` never copies or templates; only the attach path does). Fix it: pass `Provision: true` here and drop it from `startSessionAfterCreate`. Note it in spec §14.1 as a defect found during the collapse. + +`internal/tui/remote_picker.go`: `worktree.RemoteBranch` → `herd.RemoteBranch` (3 sites). + +`internal/tui/delete_teardown_test.go`: both tests keep their names and their intent — they are the regression tests for the shipped defect and they carry forward against `Teardown` (spec §10). `Model{wtSvc: …}` becomes `Model{herd: New(cfg, reg, Deps{Tmux: client})}`; `newConfirmModel(Item{Project: …, Branch: …, AgentSessionID: …})` gains `Ref: herd.Ref{…}`. `TestConfirmDeleteAll_divergedHeadSessionIsKilled` keeps `Branch: "other"` (the display value) **and** sets `Ref: {Project: "myapp", Branch: "feat"}` — that divergence is exactly what it tests, and it now cannot reach `Teardown`. + +- [ ] **Step 6: Delete `internal/worktree`** + +```bash +git rm -r internal/worktree +``` + +Run: `go build ./... && go vet ./...` and `git grep -n 'internal/worktree\|internal/session\|internal/project'` +Expected: clean build; grep returns nothing. + +- [ ] **Step 7: Verify** + +Run: `make check` +Expected: green, ≥80%. + +Then verify the defect is dead end-to-end, not just in a unit test. This exercises the real thing — spec §3.1's third row said the old kill loop was always dead code, so a passing unit test alone is not proof: + +```bash +export CODEHERD_TMUX_SOCKET=$(mktemp -d)/tmux.sock +make build +# with a profile active, create a worktree + agent session, then delete the +# worktree from the TUI and confirm no orphaned tmux session or agent process: +tmux -S "$CODEHERD_TMUX_SOCKET" list-sessions +``` +Expected after the delete: `no server running` or a list with neither `--` nor its `~sh` sibling. Before this task, the agent session survived. + +- [ ] **Step 8: Record and commit** + +Append to spec §14.1 under `**Task 5 — worktree domain**`. This is the largest stage and the one Plan 2 depends on most. Collect from its own steps: + +> `Provision` does not subsume `ch template`. `ch template` takes an arbitrary `[dir]` and supports `--dry-run`, neither of which fits `Provision`'s premise that paths derive from the `Ref`. It keeps its direct `herdtemplate` call and its own `hooks.New` — the only front end that legitimately does not go through `herd`. Its profile-blind `SessionName("", …)` was fixed in place. +> +> **Behaviour change:** `ch list worktree` now shows `(running)` under a profile. `worktree.go:593` hardcoded `""`, so the marker never appeared. +> +> **Behaviour change / bug fix:** the TUI's create-worktree form now provisions when not attaching. `form.submit` never ran file copy or templates; only the attach path did. Found during the collapse, not introduced by it. +> +> `RemoteBranches(project, fetch bool)` replaced `ListRemoteBranches` + `RemoteBranches`, which differed by one best-effort `FetchAll`. +> +> The TUI's `profileCache` was deleted rather than moved: `WithProfile` re-reads the profile TOML per switch. Record whether that was noticeable — if it is, the cache belongs on `Herd`, not on the TUI. + +Then answer §14.1's remaining prompts, which only this task can answer: + +- Did `Ref` with exported fields hold up, or did an unguarded `herd.Ref{…}` slip in? (§6.1 / §11) +- Did the ~13 methods on `Herd` still feel right after writing them all? +- Is `herd`'s real size near §8.5's ~1,550-line estimate? Did the file split survive? +- Did the session/worktree tests move intact, or need rewriting? (§10 — the known rewrites are `session_test.go`'s `Start` tests, which must now create real worktree dirs since `Launch` derives the path) +- Did the shared `fakeGit` / `fakeTmux` hold up against five hand-rolled tmux mocks and two git mocks? + +```bash +git add -A +git commit -m "refactor: fold worktree into internal/herd — the defect dies + +internal/worktree imported internal/tmux and managed sessions with it — +without the profile, and therefore wrongly. Nothing prevented it from +importing internal/session; it reimplemented the logic against the raw +tmux client instead. The enforced boundary caused the defect. + +Teardown now sits beside the session code and calls it with the profile +in hand: one kill loop, by ID, profile-correct by construction. The TUI's +duplicate loop and worktree.Delete's always-dead one both go away. + +Also dead: the profile-blind SessionName(\"\", …) literal (all 9 sites), +the three near-identical New/NewFrom/NewTracking methods, the copy+template +block that was spelled out in three front ends, and the TUI's refreshCmd ++ buildItems identity derivation, which recomputed what List now returns. + +Fixes: 'ch list worktree' shows (running) under a profile; .herd templates +render one SessionName regardless of which command created the worktree; +'ch create worktree --attach' no longer starts an unprefixed session under +a profile; the TUI's create-worktree form now provisions when not attaching." +``` + +--- + +### Task 6: close out Plan 1 and hand off + +Plan 1's contract (spec §12): the three packages are gone, `cmd`/`tui` compile against `herd`, the defect is dead structurally, coverage holds. This task proves it and writes the handoff — **the handoff is not optional**. Plans 2 and 3 are written in fresh sessions whose only context is the spec. + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md` (§12 status table, §14.1) +- Modify: `CLAUDE.md` (package layout) + +- [ ] **Step 1: Prove the contract** + +Run each and record the actual number: + +```bash +git grep -n 'internal/worktree\|internal/session\|internal/project' # want: no output +git grep -n 'semconv.SessionName' # want: only internal/herd/herd.go +git grep -rn 'tmux.NewClient(tmux.NewRealRunner())' # was 10; want: cmd/tui.go only +git grep -rn 'hooks.New(' -- cmd internal/tui # was 13; want: no output +find internal/herd -name '*.go' -not -name '*_test.go' | xargs wc -l # spec §8.5 predicted ~1,550 +make check +``` + +`git grep -n 'semconv.SessionName'` returning only `herd.go` is the whole plan in one command: nine profile-blind literals, gone, with no place to come back to. + +Some of these will not be fully clean yet — `hooks.New` in `cmd/template.go` legitimately survives (Step 5 of Task 5 explains why), and `cmd/errors.go` still has its two translators and its `os.Exit`. Those are Plan 2's scope. Record what is actually left rather than forcing it. + +- [ ] **Step 2: Update `CLAUDE.md`** + +The "Package layout" section lists `internal/session`, `internal/worktree`, `internal/project` — all deleted. Replace those three bullets with one: + +```markdown +- **`internal/herd`** — the domain. Owns projects, worktrees, and sessions together, because they are one thing: a workspace. `Herd` holds `cfg` + the active profile + the exec-boundary runners; `Ref` is identity (always the *identity* branch, never the display branch) and always carries the profile. Obtain a `Ref` from `h.Ref(project, branch)` or `Workspace.Ref` — never build one by hand. Operations: `EnsureWorkspace`, `Launch`, `List`, `Resolve`, `StopSessions`, `Teardown`, `Clone`, `Provision`, `SetStatus`. One error vocabulary lives here. +- **`internal/git`** — mechanism; `WorktreeRunner` + `CloneRunner` + `Runner` union, `RealRunner`, porcelain parsers. Never sees `cfg` or the profile. +``` + +Update the "Key design patterns" bullets that name the old packages: "Mocking via interfaces" now says `internal/tmux` exposes `Runner` and `internal/git` exposes `Runner`; tests fake at those two seams. Add: + +```markdown +- **Domain vs mechanism**: needs `cfg`, the profile, or identity to decide something → `internal/herd`. Does not → a support package. This is why `filecopy` and `herdtemplate` stayed out: they never needed the profile, which is why they were never implicated in the profile bugs. +- **Never build a `herd.Ref` by hand**: `h.Ref(project, branch)` supplies the profile; a literal `herd.Ref{Project: p, Branch: b}` is silently addressing the no-profile world. This is the convention that replaced `semconv.SessionName("", …)`, which failed nine times. +``` + +- [ ] **Step 3: Curate the handoff — spec §14.1** + +Tasks 1-5 each appended their own notes under `**Task N — …**` headings, in their own commits. **Do not rewrite them from memory — you do not have it.** Read what is there: + +```bash +git log --oneline -- docs/superpowers/specs/2026-07-15-herd-domain-package-design.md +``` + +Your job is curation, not reconstruction: + +1. **Read §14.1 top to bottom.** Delete the `_Not started_` placeholder and the commented-out prompt block — the prompts have either been answered by a task or turned out not to apply. +2. **Merge the five per-task sections into one narrative**, organised by §14's categories (assumptions wrong / API changes / decisions reversed / traps / deferred work), not by task number. A reader of Plan 2 cares what is true now, not which stage discovered it. Keep the §-references — "§10 was wrong about X" is the useful form, because it tells the next session which paragraph to distrust. +3. **Collect the behaviour changes into one list.** Four are known going in (Tasks 4 and 5 recorded them); there may be more. This list is also the changelog entry when this ships, so write it for a user, not for a reviewer. +4. **Add what only this task can measure:** the grep counts from Step 1, `herd`'s real line count and file split vs §5/§8.5's ~1,550 estimate, and the final coverage number. +5. **Add what Plan 2 inherits**, which is the section Plan 2's author reads first: `cmd/errors.go`'s two translators and its `os.Exit`-inside-`RunE`; `cmd/template.go`'s surviving `hooks.New` + direct `herdtemplate` call; whether `Model.cfg` can go away entirely; anything a task flagged as deferred. +6. **Do not smooth over a contradiction.** If Task 3 recorded that folding `project` in looked right and Task 5 recorded that `herd` feels too big, both go in. §13 called `project` the weakest link and reversible; a later session needs the disagreement, not a consensus you invented. + +If a task recorded nothing, that is a real signal — say "Tasks 1-2 surfaced nothing worth recording" rather than leaving a reader wondering whether they were skipped. + +Then set §12's status table: Plan 1 → `done`. + +- [ ] **Step 4: Verify and commit** + +Run: `make check` +Expected: green. + +```bash +git add -A +git commit -m "docs: record Plan 1 handoff and update the package layout + +internal/session, internal/worktree, and internal/project are gone. +semconv.SessionName has exactly one caller left, inside herd, so the +profile-blind literal has nowhere to come back to. + +Records the real API against the spec's §6 sketch, the four behaviour +changes this refactor shipped, and what Plan 2 inherits." +``` + +--- + +## What Plan 1 does not do + +Named here so a reviewer does not flag them as omissions: + +- **`cmd/errors.go` keeps its two translators and its `os.Exit(1)` inside `RunE`.** Spec §9 and Plan 2. Collapsing them mid-collapse would change exit codes while the domain underneath is still moving. +- **The TUI still prints raw error text** instead of matching sentinels. Spec §9 and Plan 2 — the vocabulary it needs now exists in one package, which is the prerequisite. +- **Front ends are not yet thin.** Line counts will not hit spec §8.5's estimates; that is Plan 2's target, and Plan 2's first task should re-measure rather than trust the estimate. +- **The profile × operation integration matrix is not filled.** Spec §10's three gap cells — `StopSessions`, `Teardown`, `Resolve` under an active profile — get *unit* coverage here (Tasks 4 and 5) but not integration coverage against real tmux. That is Plan 3, and it is the gate that would have caught the original defect. diff --git a/docs/superpowers/plans/2026-07-16-coverage-contract.md b/docs/superpowers/plans/2026-07-16-coverage-contract.md new file mode 100644 index 0000000..61c2c26 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-coverage-contract.md @@ -0,0 +1,483 @@ +# Coverage Contract (Plan 3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fill §10's profile × operation *integration* matrix — the three gap cells (`Resolve`, `StopSessions`, `Teardown` under an active profile) — with real-tmux integration tests at the `internal/herd` layer, plus their profile-off counterparts, so the matrix becomes a legible standing contract. + +**Architecture:** One new build-tagged integration test file, `internal/herd/matrix_integration_test.go` (`package herd`), driving the real `Herd` API against a **real** tmux server (isolated per-test via `CODEHERD_TMUX_SOCKET`) and **real** git. A two-row table (`profile off` / `profile on`) runs every operation in both columns. This is the gate that would have caught the shipped defect: under a profile the pre-refactor code rebuilt a profile-blind session name and missed the kill. + +**Tech Stack:** Go, real `tmux` binary (isolated socket), real `git` binary. No new dependencies. Test-only — **no production code changes**. + +## Global Constraints + +Copied from `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md` (§10, §3.3, §12) and `CLAUDE.md`. Every task's requirements implicitly include this section. + +- **The coverage contract (§10).** Every operation runs with profiles on *and* off. The three cells that are gaps today are `StopSessions`, `Teardown`, `Resolve` **under an active profile**; they have *unit* (fake-tmux) coverage already — this plan adds *integration* (real-tmux) coverage. This matrix is the gate that would have caught the original defect (§3.3: the bug lived in profile × {stop, delete, show}, "the quadrant nobody wrote"). +- **This is a test-only, characterization plan.** No production file changes. The cycle is: write the test → run it under `-tags integration` → **expect PASS** (the contract holds). A FAILURE is *not* a test to fix — it is the real defect the matrix exists to catch; **stop and report it** (record under §14.3, per the handoff). +- **Build tag format is exact.** Line 1 is `//go:build integration`, line 2 blank, then `package herd`. No legacy `// +build` line (matches all five existing integration files). +- **Package is `herd`** (white-box), matching `internal/herd/integration_test.go`. That file — compiled together with this one under `-tags integration` — already declares `func runGit(t *testing.T, dir string, args ...string) string`. **Reuse it; do not redeclare it** (a second declaration is a compile error). Do not redeclare any existing `package herd` test identifier (`mkMyappWorktree`, `contains`, `cloneDirPath`, the `fakeGit`/`fakeTmux` fakes, etc.). +- **Real-tmux isolation (CLAUDE.md).** Set `CODEHERD_TMUX_SOCKET` (`tmux.SocketEnvVar`) to a path under `t.TempDir()` so the `internal/tmux` `RealRunner` prepends `-S ` to every call; clear `$TMUX`; probe with a throwaway `tmux -S new-session` and `t.Skip` when it fails (missing binary / sandboxed CI); cleanup must `tmux -S kill-server` (which also reaps the `sleep` processes the sessions started). Never call bare `exec.Command("tmux", …)` without `-S `. +- **`make check` gates every task** — 80% aggregate coverage floor, integration tests, lint, build. The coverage phase runs `go test ./...` **without** the integration tag, so this file is invisible to it and cannot lower the coverage number; the new tests run in the `test-integration` phase (`go test -tags integration ./...`). Run `make check` before marking a task complete. +- **`goimports` local-prefix `github.com/xico42/codeherd`** — run `gofmt`/`goimports` after edits. + +--- + +## Scope reconciliation (read before starting) + +§12 sizes Plan 3 as: "The three gap cells — `StopSessions`, `Teardown`, `Resolve` under an active profile — are covered." Verified against the current tree: + +- **Unit (fake-tmux) coverage already exists** for the profile-scoped operations: `TestStopSessions_underProfile_matchesProfileScopedSession` (`session_test.go:637`), `TestTeardown_underProfile_killsSessionsThenDeletesWorktree` (`workspace_test.go:724`), `TestList_underProfile_findsRunningSession`, `TestSessions_filtersByActiveProfile`. This is the "unit coverage here (Tasks 4–5)" §14.1 names. +- **Integration (real-tmux) coverage does not exist** for these under a profile. The only existing `internal/herd` integration test (`integration_test.go`) is real-git-only (`Deps{Tmux: nil, …}`) and calls `EnsureWorkspace` alone. The CLI-level `cmd/profiles_integration_test.go::TestProfiles_sessionIsolationAcrossProfiles` drives real tmux but covers only profile × {create, list}. **No test anywhere constructs `herd.New(…, Deps{Tmux: tmux.NewRealRunner(), …})`.** + +So Plan 3 fills the *integration* column for the gap-cell operations at the `herd` layer, where §10's matrix names them. The two profile-off columns for these operations are included in the same tables (cheap, one `registry` parameter) so the file reads as the literal §10 matrix and guards the profile-off path from regression. + +### Non-goals (deliberately out of scope) + +- **`EnsureWorkspace` and `List` rows.** §10 marks both cells "covered" in both columns and neither is a gap; `List` profile-on is exercised by unit tests and behaviour change §14.1 #2. Not re-covered here (YAGNI). `EnsureWorkspace` is used only as *setup* (it creates the real worktree the other operations act on). +- **CLI-layer duplication.** `TestProfiles_sessionIsolationAcrossProfiles` already covers create+list through Cobra under a profile. This plan covers the mutation/query operations directly through the `Herd` API — the two together close §3.3's quadrant. No new `cmd_test` file. +- **Promoting the matrix into a `CLAUDE.md` standing rule** (a §14.3 prompt) — a post-execution decision, not a plan task. + +--- + +## File Structure + +| File | Change | Responsibility after | +|---|---|---| +| `internal/herd/matrix_integration_test.go` | Create | The §10 coverage contract: a `profile off`/`profile on` table, an isolated-real-tmux harness (`useIsolatedTmux`, `tmuxHasSession`, `setupMatrixHerd`), and one test per gap-cell operation (`Resolve`+`Launch`, `StopSessions`, `Teardown` force + non-force refuse), each run in both columns against real tmux + real git. | + +One file, three tasks. Task 1 also carries the shared harness (the deliverable of every later task depends on it); a reviewer could reject any one operation's test while approving its neighbours. + +--- + +### Task 1: Harness + the `Launch`/`Resolve` matrix rows + +**Files:** +- Create: `internal/herd/matrix_integration_test.go` + +**Interfaces:** +- Consumes (already exist): `func runGit(t *testing.T, dir string, args ...string) string` (declared in `internal/herd/integration_test.go`, same package + build tag); `herd.New`, `(*Herd).Ref`, `(*Herd).EnsureWorkspace`, `(*Herd).Launch`, `(*Herd).Resolve`, `Ref.CanonicalName()`, `SessionTypeAgent`, `EnsureOpts`, `LaunchOpts`, `Deps`; `tmux.NewRealRunner`, `tmux.SocketEnvVar`; `git.NewRealRunner`; `config.{Config,DefaultsConfig,ProjectConfig,AgentConfig,ProfileRegistry}`. +- Produces (later tasks rely on these, all in this file): the package-level var `matrixProfiles []struct{ name string; registry *config.ProfileRegistry }`; `func useIsolatedTmux(t *testing.T) string`; `func tmuxHasSession(t *testing.T, socket, name string) bool`; `func setupMatrixHerd(t *testing.T, registry *config.ProfileRegistry) (*Herd, Ref, string)` returning `(herd, identity-ref, worktree-path)`. + +**Context — why this shape:** +`setupMatrixHerd` builds a Herd on *real* tmux + git, clones a tiny upstream repo into the codeherd layout, and creates a real `feat` worktree via `EnsureWorkspace` (default add → `git worktree add -b feat main`). Profile mode is chosen by the `registry` argument: `nil` = off (what `config.Load` returns in the common case); `&config.ProfileRegistry{Active: "work"}` = on, so `h.Ref(...)` stamps `Profile: "work"` and every session name is prefixed (`work-myapp-feat`). The agent command is `sleep 300` so the tmux session stays alive for the assertions. `useIsolatedTmux` is an inline copy of the `cmd_test` helper (CLAUDE.md sanctions inlining per package; the name is free in `package herd`). + +- [ ] **Step 1: Write the failing test (compile-RED — helpers not yet defined)** + +Create `internal/herd/matrix_integration_test.go` with the build tag, imports, and **only** the `TestMatrix_LaunchAndResolve` function (which references the not-yet-written harness): + +```go +//go:build integration + +package herd + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/tmux" +) + +// TestMatrix_LaunchAndResolve fills the Launch and Resolve rows of the §10 +// matrix against real tmux: a launched agent session must exist on the server +// under its (possibly profile-prefixed) canonical name, and Resolve must find +// it by the same identity Ref that created it. +func TestMatrix_LaunchAndResolve(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, _ := setupMatrixHerd(t, col.registry) + + launched, err := h.Launch(ref, LaunchOpts{}) + if err != nil { + t.Fatalf("Launch: %v", err) + } + + if !tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Fatalf("tmux server has no session %q after Launch", ref.CanonicalName()) + } + + got, err := h.Resolve(ref, SessionTypeAgent) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.ID != launched.ID { + t.Errorf("Resolve ID = %q, want %q", got.ID, launched.ID) + } + if got.Canonical != ref.CanonicalName() { + t.Errorf("Resolve Canonical = %q, want %q", got.Canonical, ref.CanonicalName()) + } + }) + } +} +``` + +Note: `errors` is imported now because Task 3 (same file) uses it; Go tolerates it only once other code references it. To keep Step 1 compiling *up to the point of the missing helpers*, this import is exercised by Task 3's code added later — if `go vet`/compile complains about an unused `errors` import at Step 1, that is subsumed by the undefined-helper failure you are expecting in Step 2. (The import is left in place from the start so the file's import block is stable across tasks.) + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test -tags integration ./internal/herd/ -run TestMatrix_LaunchAndResolve` +Expected: FAIL — compile errors `undefined: matrixProfiles`, `undefined: useIsolatedTmux`, `undefined: setupMatrixHerd`, `undefined: tmuxHasSession` (the harness does not exist yet). + +- [ ] **Step 3: Add the harness (table + three helpers)** + +Append to `internal/herd/matrix_integration_test.go` (after the imports, before or after the test — Go order-independent): + +```go +// matrixProfiles is the two columns of the §10 coverage matrix: every +// operation is exercised with profiles off and on. "off" passes a nil +// registry (profile mode disabled — what config.Load returns in the common +// case); "on" passes a registry with an active profile, so h.Ref() stamps the +// profile and every session name is prefixed (e.g. work-myapp-feat). +var matrixProfiles = []struct { + name string + registry *config.ProfileRegistry +}{ + {"profile off", nil}, + {"profile on", &config.ProfileRegistry{Active: "work"}}, +} + +// useIsolatedTmux gives the calling test a private tmux server reached via a +// socket under t.TempDir(). It sets CODEHERD_TMUX_SOCKET so the Herd's real +// tmux runner targets the same server, clears $TMUX so new-session does not +// think it is nested, probes once, and t.Skips when tmux cannot daemonize +// (missing binary or sandboxed CI). The server is killed on cleanup so the +// socket — and any sleep processes the sessions started — disappear with the +// TempDir. Returns the socket path for direct tmux assertions. +func useIsolatedTmux(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not available") + } + socket := filepath.Join(t.TempDir(), "tmux.sock") + t.Setenv(tmux.SocketEnvVar, socket) + t.Setenv("TMUX", "") + probe := exec.Command("tmux", "-S", socket, "new-session", "-d", "-s", "__probe__", "sleep", "30") + if out, err := probe.CombinedOutput(); err != nil { + t.Skipf("tmux daemonize unavailable: %v\n%s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("tmux", "-S", socket, "kill-server").Run() + }) + return socket +} + +// tmuxHasSession reports whether the isolated server has an exactly-named +// session. The "=" target prefix forces an exact match so an agent session +// (work-myapp-feat) is never confused with its shell (work-myapp-feat~sh). +func tmuxHasSession(t *testing.T, socket, name string) bool { + t.Helper() + return exec.Command("tmux", "-S", socket, "has-session", "-t", "="+name).Run() == nil +} + +// setupMatrixHerd builds a Herd wired to REAL tmux and REAL git for the given +// profile column, with the myapp project cloned and a "feat" worktree created +// on disk. It returns the Herd, the identity Ref (carrying the profile when +// the registry is non-nil), and the worktree path. +func setupMatrixHerd(t *testing.T, registry *config.ProfileRegistry) (*Herd, Ref, string) { + t.Helper() + root := t.TempDir() + + // A tiny upstream repo with a single commit on main. + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "init") + + // Clone into the codeherd layout: /github.com/user/myapp. + projectsDir := filepath.Join(root, "projects") + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + if err := os.MkdirAll(filepath.Dir(cloneDir), 0o755); err != nil { + t.Fatal(err) + } + runGit(t, root, "clone", remote, cloneDir) + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir, Agent: "agent"}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + Agents: map[string]config.AgentConfig{ + // A long sleep keeps the tmux session alive for the assertions. + "agent": {Cmd: "sleep", Args: []string{"300"}}, + }, + } + h := New(cfg, registry, Deps{Tmux: tmux.NewRealRunner(), Git: git.NewRealRunner()}) + + ref := h.Ref("myapp", "feat") + ws, err := h.EnsureWorkspace(ref, EnsureOpts{}) + if err != nil { + t.Fatalf("EnsureWorkspace: %v", err) + } + return h, ref, ws.Path +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `gofmt -w internal/herd/matrix_integration_test.go && go test -tags integration ./internal/herd/ -run TestMatrix_LaunchAndResolve -v` +Expected: PASS for both subtests (`profile off`, `profile on`) — or the whole test SKIPs if tmux cannot daemonize in this environment. A genuine assertion FAILURE means real tmux disagrees with the domain (e.g. Resolve cannot find a profile-prefixed session) — that is a real defect; stop and report it under §14.3, do not "fix" the test. + +- [ ] **Step 5: Run the full gate** + +Run: `make check` +Expected: green — coverage ≥80% (unchanged; the coverage phase omits `-tags integration` so this file is invisible to it), integration tests pass (or skip), lint clean, build OK. + +- [ ] **Step 6: Commit** + +```bash +git add internal/herd/matrix_integration_test.go +git commit -m "test(herd): real-tmux matrix — Launch/Resolve, profile off+on + +First cell of the §10 coverage contract. Adds an isolated-real-tmux harness +(useIsolatedTmux, tmuxHasSession, setupMatrixHerd) and a two-column table +(profile off / profile on) exercising Launch and Resolve against a real tmux +server. Resolve under a profile is one of the three §10 gap cells; this is its +integration coverage. + +Refs spec §10, §3.3. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: The `StopSessions` matrix row + +**Files:** +- Modify: `internal/herd/matrix_integration_test.go` (append one test) + +**Interfaces:** +- Consumes: the Task 1 harness (`matrixProfiles`, `useIsolatedTmux`, `setupMatrixHerd`, `tmuxHasSession`); `(*Herd).Launch`, `(*Herd).StopSessions`; `LaunchOpts`, `StopOpts`, `SessionTypeAgent`, `SessionTypeShell`, `Ref.CanonicalName()`. +- Produces: `func TestMatrix_StopSessions(t *testing.T)`. + +**Context — why this shape:** +`StopSessions(ref, StopOpts{All: true})` must stop *both* the agent and the shell session and return their handles. The shell's tmux name is `Ref.CanonicalName() + "~sh"` (that is exactly what `semconv.ShellSessionName` returns). Under a profile this is the cell that was a gap: the pre-refactor code rebuilt a profile-blind name (`myapp-feat`) and missed the real `work-myapp-feat`. This is a characterization test on existing code — expect PASS; a FAIL is a real defect. + +- [ ] **Step 1: Write the test** + +Append to `internal/herd/matrix_integration_test.go`: + +```go +// TestMatrix_StopSessions fills the StopSessions row: after launching both an +// agent and a shell session, StopSessions(All) must stop both, return two +// handles, and leave neither on the real tmux server. Under a profile this is +// the cell that was a gap — the pre-refactor code rebuilt a profile-blind name +// and missed the profile-prefixed session. +func TestMatrix_StopSessions(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, _ := setupMatrixHerd(t, col.registry) + + if _, err := h.Launch(ref, LaunchOpts{Type: SessionTypeAgent}); err != nil { + t.Fatalf("Launch agent: %v", err) + } + if _, err := h.Launch(ref, LaunchOpts{Type: SessionTypeShell}); err != nil { + t.Fatalf("Launch shell: %v", err) + } + + agentName := ref.CanonicalName() + shellName := ref.CanonicalName() + "~sh" // == semconv.ShellSessionName(...) + if !tmuxHasSession(t, socket, agentName) || !tmuxHasSession(t, socket, shellName) { + t.Fatalf("precondition: expected both sessions running (agent=%v shell=%v)", + tmuxHasSession(t, socket, agentName), tmuxHasSession(t, socket, shellName)) + } + + stopped, err := h.StopSessions(ref, StopOpts{All: true}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 2 { + t.Errorf("StopSessions stopped %d sessions, want 2", len(stopped)) + } + if tmuxHasSession(t, socket, agentName) { + t.Errorf("agent session %q survived StopSessions", agentName) + } + if tmuxHasSession(t, socket, shellName) { + t.Errorf("shell session %q survived StopSessions", shellName) + } + }) + } +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `gofmt -w internal/herd/matrix_integration_test.go && go test -tags integration ./internal/herd/ -run TestMatrix_StopSessions -v` +Expected: PASS for both subtests (or SKIP if tmux unavailable). A FAIL under `profile on` is the real gap the matrix exists to catch — report it under §14.3, do not alter the test to pass. + +- [ ] **Step 3: Run the full gate** + +Run: `make check` +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git add internal/herd/matrix_integration_test.go +git commit -m "test(herd): real-tmux matrix — StopSessions, profile off+on + +Second §10 gap cell. Launches an agent and a shell session, then asserts +StopSessions(All) stops both by ID and neither survives on the real tmux +server — under a profile, the exact case the pre-refactor profile-blind name +rebuild missed. + +Refs spec §10, §3.3. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: The `Teardown` matrix row (force + non-force refuse) — the shipped defect + +**Files:** +- Modify: `internal/herd/matrix_integration_test.go` (append two tests) + +**Interfaces:** +- Consumes: the Task 1 harness (`matrixProfiles`, `useIsolatedTmux`, `setupMatrixHerd`, `tmuxHasSession`); `(*Herd).Launch`, `(*Herd).Teardown`; `LaunchOpts`, `TeardownOpts`, `ErrSessionRunning`, `Ref.CanonicalName()`; the `errors` and `os` imports already in the file. +- Produces: `func TestMatrix_Teardown(t *testing.T)`, `func TestMatrix_TeardownRefusesRunning(t *testing.T)`. + +**Context — why this shape:** +`Teardown` is the row the shipped defect lived in (§2, §8.3): the TUI killed by ID then ran a second profile-blind kill loop that missed, and force-deleted the worktree anyway — orphaning the agent against a gone directory. With `Force: true`, `Teardown` must kill the (profile-prefixed) session **and** remove the worktree from disk. Without `Force`, it must refuse with `ErrSessionRunning` while a session is live and leave both the session and worktree intact (`workspace.go:334-345` returns `ErrSessionRunning` before stopping anything). These are characterization tests on existing code — expect PASS; a surviving session under `profile on` in the force case is precisely the orphaned-agent bug. + +- [ ] **Step 1: Write both tests** + +Append to `internal/herd/matrix_integration_test.go`: + +```go +// TestMatrix_Teardown fills the Teardown row — the row the shipped defect +// lived in. With Force, Teardown must kill the (profile-prefixed) session AND +// remove the worktree from disk. A surviving session under "profile on" is +// exactly the orphaned-agent bug the matrix exists to catch. +func TestMatrix_Teardown(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, wtPath := setupMatrixHerd(t, col.registry) + + if _, err := h.Launch(ref, LaunchOpts{}); err != nil { + t.Fatalf("Launch: %v", err) + } + if !tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Fatalf("precondition: session %q not running", ref.CanonicalName()) + } + + if err := h.Teardown(ref, TeardownOpts{Force: true}); err != nil { + t.Fatalf("Teardown: %v", err) + } + + if tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Errorf("session %q survived Teardown (orphaned agent)", ref.CanonicalName()) + } + if _, err := os.Stat(wtPath); !os.IsNotExist(err) { + t.Errorf("worktree %q still on disk after Teardown (stat err=%v)", wtPath, err) + } + }) + } +} + +// TestMatrix_TeardownRefusesRunning is the non-force half: Teardown without +// Force must refuse with ErrSessionRunning while a session is live, and must +// leave both the session and the worktree intact. +func TestMatrix_TeardownRefusesRunning(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, wtPath := setupMatrixHerd(t, col.registry) + + if _, err := h.Launch(ref, LaunchOpts{}); err != nil { + t.Fatalf("Launch: %v", err) + } + + err := h.Teardown(ref, TeardownOpts{Force: false}) + if !errors.Is(err, ErrSessionRunning) { + t.Fatalf("Teardown(Force:false) err = %v, want ErrSessionRunning", err) + } + if !tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Errorf("session %q was killed despite refusal", ref.CanonicalName()) + } + if _, err := os.Stat(wtPath); err != nil { + t.Errorf("worktree %q removed despite refusal: %v", wtPath, err) + } + }) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they pass** + +Run: `gofmt -w internal/herd/matrix_integration_test.go && go test -tags integration ./internal/herd/ -run 'TestMatrix_Teardown' -v` +Expected: PASS for all four subtests (both tests × both columns), or SKIP if tmux unavailable. The `-run 'TestMatrix_Teardown'` pattern matches both `TestMatrix_Teardown` and `TestMatrix_TeardownRefusesRunning`. A surviving session under `profile on` in the force case is the shipped defect — report it under §14.3. + +- [ ] **Step 3: Run the whole matrix once, then the full gate** + +Run: `go test -tags integration ./internal/herd/ -run TestMatrix -v && make check` +Expected: all `TestMatrix_*` subtests PASS (or SKIP together); `make check` green. Running the whole `TestMatrix` family confirms the harness serves every operation and there is no cross-test tmux leakage (each subtest gets its own isolated socket + `kill-server` cleanup). + +- [ ] **Step 4: Commit** + +```bash +git add internal/herd/matrix_integration_test.go +git commit -m "test(herd): real-tmux matrix — Teardown force + refuse, profile off+on + +Third §10 gap cell, the row the shipped defect lived in. Force teardown must +kill the profile-prefixed session AND remove the worktree from disk; non-force +must refuse with ErrSessionRunning and touch nothing. Both run profile off and +on against real tmux. Completes the §10 coverage contract. + +Refs spec §10, §2, §8.3. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-Review + +**1. Spec coverage (§10 — the section Plan 3 owns):** +- "Integration — the coverage contract. Every operation runs with profiles on *and* off." — every `TestMatrix_*` iterates `matrixProfiles` = {off, on}. ✅ +- Gap cell `Resolve` under profile — Task 1 `TestMatrix_LaunchAndResolve` (`profile on` subtest). ✅ +- Gap cell `StopSessions` under profile — Task 2 `TestMatrix_StopSessions` (`profile on`). ✅ +- Gap cell `Teardown` under profile — Task 3 `TestMatrix_Teardown` + `TestMatrix_TeardownRefusesRunning` (`profile on`). ✅ +- "This matrix is the gate that would have caught the original defect" — Task 3 asserts the profile-prefixed session is gone AND the worktree removed after force teardown (the exact orphan condition of §2/§8.3). ✅ +- §3.3 "the quadrant nobody wrote" (profile × {stop, delete, show}) — stop=`StopSessions` (Task 2), delete=`Teardown` (Task 3), show=`Resolve` (Task 1), all under a profile. ✅ +- "`herd` tests fake `tmux.Runner`" (unit, §10) vs integration — this plan is the integration layer; unit fakes already exist and are untouched. ✅ + +**2. Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to Task N". Every code step shows complete code. The one prose note (Step 1 `errors` import) is a real compile-order caveat, not a placeholder. ✅ + +**3. Type/name consistency:** +- Harness signatures identical across the interface blocks, Step 3 implementation, and every call site: `useIsolatedTmux(t) string`, `tmuxHasSession(t, socket, name string) bool`, `setupMatrixHerd(t, registry) (*Herd, Ref, string)`, `matrixProfiles` with fields `name`/`registry`. ✅ +- Reuses `runGit` from `integration_test.go` — not redeclared. Checked against the full `package herd` test-identifier list; `useIsolatedTmux`, `tmuxHasSession`, `setupMatrixHerd`, `matrixProfiles`, and all `TestMatrix_*` names are free. ✅ +- API calls match as-built signatures: `New(cfg, registry, Deps{Tmux, Git})`, `h.Ref(project, branch)`, `EnsureWorkspace(ref, EnsureOpts{})`, `Launch(ref, LaunchOpts{Type})`, `Resolve(ref, SessionTypeAgent)`, `StopSessions(ref, StopOpts{All:true}) ([]Handle, error)`, `Teardown(ref, TeardownOpts{Force}) error`, `ErrSessionRunning`, `Ref.CanonicalName()`. Verified against `internal/herd/{herd,session,workspace,errors}.go`. ✅ +- Shell tmux name `ref.CanonicalName() + "~sh"` matches `semconv.ShellSessionName`. Agent config `{Cmd:"sleep", Args:["300"]}` → `Command()` = `"sleep 300"`. ✅ + +**Behaviour / process notes for the §14.3 handoff:** +1. This plan changes **no production code** — it is pure integration coverage. If any `TestMatrix_*` fails on first green-run, that is a real defect (record it), not a plan error. +2. The new tests run only in `make check`'s `test-integration` phase; they do not affect the coverage percentage (coverage runs without the tag). `make lint`/`make build` also omit the tag, so the file is exercised only under `-tags integration`. +3. §14.3 prompts to answer after execution: did the matrix find real bugs or confirm the design? is it cheap/fast enough to keep green (each subtest starts and kills a real tmux server + `sleep` processes)? should it become a `CLAUDE.md` standing rule? + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-07-16-coverage-contract.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute the three tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** + +After execution, record findings in the spec's §14.3 ("After Plan 3 — the coverage contract"), then offer `superpowers:finishing-a-development-branch` — this is the final plan of the herd-domain refactor. diff --git a/docs/superpowers/plans/2026-07-16-front-end-thinning.md b/docs/superpowers/plans/2026-07-16-front-end-thinning.md new file mode 100644 index 0000000..28b6a6d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-front-end-thinning.md @@ -0,0 +1,488 @@ +# Front-End Thinning (Plan 2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give codeherd's CLI one error vocabulary — a single translator that returns (never `os.Exit`s) — and teach the TUI to match the same `herd` sentinels instead of printing raw internal error strings. + +**Architecture:** `internal/herd` already owns the whole sentinel vocabulary (`internal/herd/errors.go`). This plan finishes the front-end half of §9: `cmd/errors.go`'s two translators (`worktreeErr`/`sessionErr`) collapse into one `herdErr` that **returns** a user-facing error and lets `Execute` print it and `main` set the exit code; and the TUI gains a `humanize(err)` that maps the same sentinels to concise status lines. `herd` still never formats user-facing text — presentation stays in each front end. + +**Tech Stack:** Go, Cobra (CLI), Bubble Tea v2 (TUI). No new dependencies. + +## Global Constraints + +Copied from `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md` (§9) and `CLAUDE.md`. Every task's requirements implicitly include this section. + +- **One error vocabulary.** Front ends match `herd` sentinels and nothing else; all sentinels already live in `internal/herd/errors.go`. `herd` never formats user-facing text — presentation stays in each front end. (§9) +- **`herdErr` returns, never exits.** The translator returns an error; `Execute` prints it and `main` exits non-zero. Printing + `os.Exit(1)` inside `RunE` is the wart being removed — it made the trailing `return nil` unreachable and bypassed `Execute`'s error path. (§9) +- **`make check` gates every task** — 80% aggregate coverage floor, integration tests, lint (`golangci-lint`), build. Run it before marking a task complete. +- **`wrapcheck` is active on production code** (`cmd/`, `internal/tui/`), disabled for `_test.go`. Returning a freshly created `fmt.Errorf(...)` or a local `err` variable is allowed; returning an unwrapped error straight from another package's call in the `return` statement is not. +- **`staticcheck` runs all checks (`ST1005` included).** Error strings passed to `errors.New`/`fmt.Errorf` must not start with a capital letter and must not end with `.`, `:`, or `!`. (TUI status strings are plain `string`, not errors — `ST1005` does not apply to them.) +- **`goimports` with local-prefix `github.com/xico42/codeherd`** — run `gofmt`/`goimports` after edits so unused imports are dropped and grouping is correct. `make lint` enforces it. +- **TDD, frequent commits.** RED (failing/uncompilable test) → GREEN (minimal implementation) → refactor → commit per task. + +--- + +## Scope reconciliation (read before starting) + +The spec's §12 sketched Plan 2 as "~600 fewer lines across the front ends." **Most of that thinning already landed in Plan 1** (see §14.1). Verified current state: + +- `cmd/services.go` and its `*ForProfile` shims — **already deleted** (with `activeProfile()`). +- `Model.sesSvc` / `Model.projSvc` dead fields — **already gone**. +- The TUI `profileCache` — **already deleted**; `switchProfile` calls `m.herd.WithProfile(next)`. +- Front-end line counts are **already at or near the §8.5 targets** (`cmd/session.go` 275, `cmd/worktree.go` 175, `internal/tui/actions.go` 279, `internal/tui/model.go` 578). There is no bulk of dead code left to remove. + +What genuinely remains from §9 is the **error-vocabulary work**, and that is this plan: + +1. Collapse `cmd/errors.go`'s two translators into one and remove `os.Exit` from `RunE` (Task 1). +2. Teach the TUI to match `herd` sentinels instead of printing raw errors (Task 2). + +### Measured non-goals (deliberately out of scope) + +Two items §14.1 flagged for Plan 2 were measured and **intentionally kept**: + +- **`Model.cfg` stays.** It is a redundant cache of `m.herd.Config()`, but ~30 TUI test sites build `Model{cfg: cfg}` *without* a herd and read config through the field (plus `model_export_test.go`'s `CurrentConfigForTest()`). Removing it forces every one of those tests to construct a real `Herd` — the exact "ceremony that suppresses tests" §11 rejected when it kept `Ref`'s fields exported. The field is manually resynced at the only two sites that swap the herd (`NewModel`, `switchProfile`), so drift risk is negligible. §14.1's own guidance: "if the per-keypress read ever bites, the cache belongs on `Herd`, not the TUI" — it has not bitten. Keep it. +- **The inline `tmux.NewClient` sites stay.** `cmd/session.go`'s `execTmuxAttach` and the TUI's `switchClientCmd` build a tmux client only to call `SwitchClient` for **in-place interactive attach**. The design deliberately keeps interactive attach in the front ends (cf. `syscall.Exec`; `SetStatus` is the *only* name-addressed escape hatch into `herd`). These are exec mechanism for attach, not the dead service-injection smell §3.2 was about. Leave them. + +`cmd/template.go` keeps its own `hooks.New` and direct `herdtemplate` call — §14.1 already ruled this the one front end that legitimately does not route through `herd`. Untouched here. + +--- + +## File Structure + +| File | Change | Responsibility after | +|---|---|---| +| `cmd/errors.go` | Rewrite | One translator, `herdErr(project, branch string, err error) error`, that returns friendly errors. No printing, no `os.Exit`. | +| `cmd/root.go` | Modify (`Execute`) | Print the returned error once, prefixed `Error: `, to `os.Stderr`; return non-nil so `main` exits 1. | +| `cmd/session.go` | Modify (6 call sites) | `return herdErr(project, branch, err)` at each translator call. | +| `cmd/worktree.go` | Modify (3 call sites) | `return herdErr(project, branch, err)` at each translator call. | +| `cmd/errors_internal_test.go` | Create | Unit tests for `herdErr` — one per sentinel + the `SessionExistsError` path + default passthrough. | +| `internal/tui/errors.go` | Create | `humanize(err error) string` — maps `herd` sentinels to concise status lines; default falls through to `err.Error()`. | +| `internal/tui/model.go` | Modify (2 render sites) | `m.statusMsg = humanize(msg.err)` and `m.remotePicker.errText = humanize(msg.err)`. | +| `internal/tui/errors_internal_test.go` | Create | Unit tests for `humanize` — one per sentinel + `SessionExistsError` + `nil` + default. | + +Two independently reviewable tasks; a reviewer could reject the TUI change while approving the CLI change or vice versa. + +--- + +### Task 1: Unify the CLI error translators into `herdErr` and remove `os.Exit` from `RunE` + +**Files:** +- Modify: `cmd/errors.go` (full rewrite of both functions into one) +- Modify: `cmd/root.go:81-89` (`Execute`) +- Modify: `cmd/session.go:98,155,175,217,233,262` (call sites) +- Modify: `cmd/worktree.go:110,126,170` (call sites) +- Test: `cmd/errors_internal_test.go` (create) + +**Interfaces:** +- Consumes (already exist in `internal/herd/errors.go`): sentinels `herd.ErrNotCloned`, `herd.ErrWorktreeExists`, `herd.ErrWorktreeNotFound`, `herd.ErrSessionRunning`, `herd.ErrSessionExists`, `herd.ErrSessionNotFound`, `herd.ErrPathNotFound`; typed error `*herd.SessionExistsError` with fields `Ref herd.Ref` (`.Project`, `.Branch`) and `Type herd.SessionType`. +- Produces: `func herdErr(project, branch string, err error) error` in package `cmd`. Replaces `worktreeErr(cmd, project, branch, err)` and `sessionErr(cmd, err)`, both of which are deleted. + +**Context — why this shape:** +`herdErr` drops the `*cobra.Command` parameter because it no longer prints; it only builds and returns the error. `Execute` is the single print site. `runCmd` (the test harness) returns `Execute`'s error, so every friendly message becomes assertable via `err.Error()` — the coverage the old `os.Exit(1)` made impossible (see the comments in `cmd/session_test.go:88,109-112` that deliberately steered around the exiting branches). + +- [ ] **Step 1: Write the failing unit tests** + +Create `cmd/errors_internal_test.go`: + +```go +package cmd + +import ( + "errors" + "testing" + + "github.com/xico42/codeherd/internal/herd" +) + +func TestHerdErr_notCloned(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrNotCloned) + want := "myapp is not cloned. Run 'ch clone project myapp' first" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_worktreeExists(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrWorktreeExists) + want := "worktree myapp/feat already exists" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_worktreeNotFound(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrWorktreeNotFound) + want := "worktree myapp/feat not found. Run 'ch create worktree myapp feat' first" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_sessionRunning(t *testing.T) { + got := herdErr("myapp", "feat", herd.ErrSessionRunning) + want := "session myapp-feat is running. Stop it first or use --force" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_sessionExists_carriesRefFromTypedError(t *testing.T) { + se := &herd.SessionExistsError{ + Ref: herd.Ref{Project: "myapp", Branch: "feat"}, + Type: herd.SessionTypeAgent, + } + got := herdErr("ignored", "ignored", se) + want := "session myapp/feat (agent) already exists. Attach with 'ch attach session myapp feat'" + if got == nil || got.Error() != want { + t.Fatalf("herdErr() = %v, want %q", got, want) + } +} + +func TestHerdErr_unknownSentinel_passesThrough(t *testing.T) { + raw := errors.New("boom") + got := herdErr("myapp", "feat", raw) + if got != raw { + t.Fatalf("herdErr() = %v, want the original error %v", got, raw) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./cmd/ -run TestHerdErr` +Expected: FAIL — `undefined: herdErr` (compile error; the function does not exist yet). + +- [ ] **Step 3: Rewrite `cmd/errors.go` as the single translator** + +Replace the entire contents of `cmd/errors.go` with: + +```go +package cmd + +import ( + "errors" + "fmt" + + "github.com/xico42/codeherd/internal/herd" +) + +// herdErr is the CLI's single error translator. Every command funnels its +// domain errors through here, matching herd sentinels and nothing else +// (the one error vocabulary — herd owns the sentinels, the front end owns +// the presentation). +// +// It RETURNS the error rather than printing and calling os.Exit. Execute +// prints it once (prefixed "Error: ") and main exits non-zero. The previous +// shape printed and called os.Exit(1) inside RunE, which made the trailing +// `return nil` unreachable and bypassed Execute's error path. +// +// project and branch supply context for the friendly messages; pass the +// identity values in scope at the call site (or ws.Ref.Project / +// ws.Ref.Branch). For ErrSessionExists the context is read from the typed +// *herd.SessionExistsError instead, so the two positional args are ignored +// on that branch. +func herdErr(project, branch string, err error) error { + switch { + case errors.Is(err, herd.ErrNotCloned): + return fmt.Errorf("%s is not cloned. Run 'ch clone project %s' first", project, project) + case errors.Is(err, herd.ErrWorktreeExists): + return fmt.Errorf("worktree %s/%s already exists", project, branch) + case errors.Is(err, herd.ErrWorktreeNotFound): + return fmt.Errorf("worktree %s/%s not found. Run 'ch create worktree %s %s' first", project, branch, project, branch) + case errors.Is(err, herd.ErrSessionRunning): + return fmt.Errorf("session %s-%s is running. Stop it first or use --force", project, branch) + case errors.Is(err, herd.ErrSessionExists): + var sesErr *herd.SessionExistsError + if errors.As(err, &sesErr) { + return fmt.Errorf("session %s/%s (%s) already exists. Attach with 'ch attach session %s %s'", + sesErr.Ref.Project, sesErr.Ref.Branch, sesErr.Type, sesErr.Ref.Project, sesErr.Ref.Branch) + } + return err + default: + return err + } +} +``` + +Note: `ErrSessionNotFound` and `ErrPathNotFound` need no explicit branch — the raw domain error already carries a clear message (`"session not found: ..."`, `"worktree path not found: ..."`), so `default: return err` handles them exactly as the old `sessionErr` did (it merely reprinted the raw error for those cases). No leading-capital / trailing-punctuation in any new string → `ST1005` clean. + +- [ ] **Step 4: Update `Execute` to print the returned error, prefixed** + +In `cmd/root.go`, change the `Execute` body (currently lines 81-89): + +```go +func Execute(version string) error { + resetAllFlags(rootCmd) + rootCmd.Version = version + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + return fmt.Errorf("%w", err) + } + return nil +} +``` + +Only the print line changes: `fmt.Fprintln(os.Stderr, err)` → `fmt.Fprintln(os.Stderr, "Error:", err)`. This restores the `Error: ` affordance the old translators printed, now centralized for *every* user-facing error (config-load errors included). `main.go` already exits 1 on a non-nil return — unchanged. + +- [ ] **Step 5: Update the 9 call sites** + +In `cmd/session.go`, replace each translator call with `herdErr` (all sites have `project, branch` in scope): +- line 98 (`ShowSessionCmd.Run`): `return sessionErr(cmd, err)` → `return herdErr(project, branch, err)` +- line 155 (`CreateSessionCmd.Run`): `return worktreeErr(cmd, project, branch, err)` → `return herdErr(project, branch, err)` +- line 175 (`CreateSessionCmd.Run`): `return sessionErr(cmd, err)` → `return herdErr(project, branch, err)` +- line 217 (`DeleteSessionCmd.Run`): `return sessionErr(cmd, err)` → `return herdErr(project, branch, err)` +- line 233 (`DeleteSessionCmd.Run`): `return sessionErr(cmd, err)` → `return herdErr(project, branch, err)` +- line 262 (`AttachSessionCmd.Run`): `return sessionErr(cmd, err)` → `return herdErr(project, branch, err)` + +In `cmd/worktree.go`: +- line 110 (`CreateWorktreeCmd.Run`): `return worktreeErr(cmd, project, posBranch, err)` → `return herdErr(project, posBranch, err)` +- line 126 (`CreateWorktreeCmd.Run`, the `--attach` block): `return sessionErr(cmd, err)` → `return herdErr(ws.Ref.Project, ws.Ref.Branch, err)` (use `ws.Ref` — `Track` may have derived a different local branch; it is the authoritative identity here) +- line 170 (`DeleteWorktreeCmd.Run`): `return worktreeErr(cmd, project, branch, err)` → `return herdErr(project, branch, err)` + +- [ ] **Step 6: Run `gofmt`/`goimports` and the package tests** + +Run: `gofmt -w cmd/errors.go cmd/root.go cmd/session.go cmd/worktree.go && go test ./cmd/` +Expected: PASS. The new `TestHerdErr_*` pass; the pre-existing session/worktree tests still pass (the ones that previously steered around `os.Exit` now flow through `default: return err` exactly as before). `cmd/errors.go` no longer imports `os` or `github.com/spf13/cobra`; `goimports` drops them. + +- [ ] **Step 7: Run the full gate** + +Run: `make check` +Expected: green — coverage ≥80% (Task 1 adds directly-testable error paths, nudging `cmd` coverage up), integration passes, lint clean, build OK. + +- [ ] **Step 8: Commit** + +```bash +git add cmd/errors.go cmd/root.go cmd/session.go cmd/worktree.go cmd/errors_internal_test.go +git commit -m "refactor(cmd): one error translator, no os.Exit in RunE + +Collapse worktreeErr/sessionErr into a single herdErr that RETURNS a +user-facing error instead of printing and calling os.Exit(1) inside RunE. +Execute prints it once (prefixed \"Error: \") and main sets the exit code. +This makes the friendly sentinel messages returnable and therefore +testable — the paths the old os.Exit made impossible to cover. + +Behaviour: all user-facing errors (config-load included) now print with a +consistent \"Error: \" prefix via Execute; exit codes unchanged. + +Refs spec §9. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: Teach the TUI to match `herd` sentinels instead of rendering raw errors + +**Files:** +- Create: `internal/tui/errors.go` +- Modify: `internal/tui/model.go:232` and `internal/tui/model.go:262` +- Test: `internal/tui/errors_internal_test.go` (create) + +**Interfaces:** +- Consumes: the same `herd` sentinels and `*herd.SessionExistsError` as Task 1. +- Produces: `func humanize(err error) string` in package `tui`. + +**Context — why this shape:** +Today the dashboard sets `m.statusMsg = msg.err.Error()` (model.go:232) and `m.remotePicker.errText = msg.err.Error()` (model.go:262), leaking raw internal strings like `"project not cloned"`. §9: "The TUI stops rendering raw errors … One vocabulary lets the TUI match the same sentinels." `humanize` is TUI-local presentation (the return is a plain status `string`, so `ST1005` does not apply — sentences with capitals and periods are fine). Unlike the CLI translator, the TUI `errMsg` carries no project/branch, so context-free messages are used except for `*herd.SessionExistsError`, which carries its own `Ref`. + +- [ ] **Step 1: Write the failing unit tests** + +Create `internal/tui/errors_internal_test.go`: + +```go +package tui + +import ( + "errors" + "testing" + + "github.com/xico42/codeherd/internal/herd" +) + +func TestHumanize_nil(t *testing.T) { + if got := humanize(nil); got != "" { + t.Fatalf("humanize(nil) = %q, want empty", got) + } +} + +func TestHumanize_sentinels(t *testing.T) { + cases := []struct { + name string + err error + want string + }{ + {"notCloned", herd.ErrNotCloned, "Project is not cloned — clone it first."}, + {"worktreeExists", herd.ErrWorktreeExists, "Worktree already exists."}, + {"worktreeNotFound", herd.ErrWorktreeNotFound, "Worktree not found."}, + {"sessionNotFound", herd.ErrSessionNotFound, "No such session."}, + {"sessionRunning", herd.ErrSessionRunning, "Session is running — stop it first."}, + {"pathNotFound", herd.ErrPathNotFound, "Worktree path not found."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := humanize(tc.err); got != tc.want { + t.Fatalf("humanize(%v) = %q, want %q", tc.err, got, tc.want) + } + }) + } +} + +func TestHumanize_sessionExists_usesRef(t *testing.T) { + se := &herd.SessionExistsError{ + Ref: herd.Ref{Project: "myapp", Branch: "feat"}, + Type: herd.SessionTypeAgent, + } + want := "Session myapp/feat (agent) already exists." + if got := humanize(se); got != want { + t.Fatalf("humanize() = %q, want %q", got, want) + } +} + +func TestHumanize_unknown_passesThrough(t *testing.T) { + raw := errors.New("some raw failure") + if got := humanize(raw); got != "some raw failure" { + t.Fatalf("humanize() = %q, want the raw message", got) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run TestHumanize` +Expected: FAIL — `undefined: humanize` (compile error). + +- [ ] **Step 3: Create `internal/tui/errors.go`** + +```go +package tui + +import ( + "errors" + "fmt" + + "github.com/xico42/codeherd/internal/herd" +) + +// humanize maps a herd domain error to a concise, user-facing status line for +// the TUI. It matches the same herd sentinels the CLI translator does (one +// error vocabulary) so the dashboard stops surfacing raw internal error +// strings. Unknown errors fall through to their own message. +// +// The TUI errMsg carries no project/branch, so most messages are +// context-free; ErrSessionExists is the exception — its typed error carries +// the Ref, so the message names the session. +func humanize(err error) string { + if err == nil { + return "" + } + switch { + case errors.Is(err, herd.ErrNotCloned): + return "Project is not cloned — clone it first." + case errors.Is(err, herd.ErrWorktreeExists): + return "Worktree already exists." + case errors.Is(err, herd.ErrWorktreeNotFound): + return "Worktree not found." + case errors.Is(err, herd.ErrSessionExists): + var se *herd.SessionExistsError + if errors.As(err, &se) { + return fmt.Sprintf("Session %s/%s (%s) already exists.", se.Ref.Project, se.Ref.Branch, se.Type) + } + return "Session already exists." + case errors.Is(err, herd.ErrSessionNotFound): + return "No such session." + case errors.Is(err, herd.ErrSessionRunning): + return "Session is running — stop it first." + case errors.Is(err, herd.ErrPathNotFound): + return "Worktree path not found." + default: + return err.Error() + } +} +``` + +- [ ] **Step 4: Wire the two render sites in `internal/tui/model.go`** + +At line 232 (the `errMsg` case): + +```go + case errMsg: + m.busy = "" + if msg.err != nil { + m.statusMsg = humanize(msg.err) + } + return m, nil +``` + +At line 262 (the `remoteBranchesMsg` error branch): + +```go + if msg.err != nil { + m.remotePicker.errText = humanize(msg.err) +``` + +Both change only `msg.err.Error()` → `humanize(msg.err)`. + +- [ ] **Step 5: Run the package tests** + +Run: `gofmt -w internal/tui/errors.go internal/tui/model.go && go test ./internal/tui/` +Expected: PASS — `TestHumanize_*` pass and the existing TUI suite is unaffected (raw-string assertions, if any, only covered non-sentinel errors, which still pass through `default`). + +- [ ] **Step 6: Run the full gate** + +Run: `make check` +Expected: green — coverage ≥80%, integration, lint, build all OK. + +- [ ] **Step 7: Commit** + +```bash +git add internal/tui/errors.go internal/tui/model.go internal/tui/errors_internal_test.go +git commit -m "feat(tui): humanize herd sentinels instead of raw errors + +Add humanize(err) mapping the herd sentinel vocabulary to concise status +lines, and route the dashboard's two error-render sites (statusMsg and the +remote-picker errText) through it. The TUI no longer surfaces raw internal +error strings; it matches the same sentinels the CLI does. + +Refs spec §9. + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-Review + +**1. Spec coverage (§9 — the only section Plan 2 owns):** +- "One vocabulary. All sentinels move to `herd`" — done in Plan 1; Task 1 consumes them. ✅ +- "`cmd/errors.go` has two translators … One package yields one translator" — Task 1 collapses `worktreeErr`+`sessionErr` → `herdErr`. ✅ +- "Fix the `os.Exit` wart … becomes a translator that returns an error and lets Cobra exit" — Task 1 removes both `os.Exit(1)`; `Execute` prints, `main` exits. ✅ +- "The TUI stops rendering raw errors … match the same sentinels" — Task 2 `humanize` at both render sites. ✅ +- "`herd` never formats user-facing text" — preserved; all presentation is in `cmd`/`tui`. ✅ +- §12 "no front end constructs a service or builds a session name" — already true after Plan 1 (verified in Scope reconciliation); the two inline `tmux.NewClient` attach sites are exec mechanism, not services (measured non-goal). ✅ + +**2. Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to Task N". Every code step shows complete code. ✅ + +**3. Type consistency:** +- `herdErr(project, branch string, err error) error` — signature identical in the interface block, the implementation (Step 3), and all 9 call sites (Step 5). ✅ +- `humanize(err error) string` — identical in interface block, implementation, tests, and both wiring sites. ✅ +- `*herd.SessionExistsError` field access (`.Ref.Project`, `.Ref.Branch`, `.Type`) matches `internal/herd/errors.go`. ✅ +- `herd.SessionTypeAgent` renders as `"agent"` (`= semconv.SessionTypeAgent`, herd.go:28), matching the expected strings in both tasks' tests. ✅ + +**Behaviour changes to record in the branch handoff (§14.2):** +1. All user-facing CLI errors now print with a consistent `Error: ` prefix via `Execute` (previously only the two translators did; config-load and other `RunE` errors gained it). Exit codes unchanged. +2. The sentinel-branch friendly messages are now returned (hence testable) rather than printed-then-`os.Exit`ed. `err.Error()` carries the friendly text without the `Error: ` prefix (the prefix is added only at print time in `Execute`). +3. The TUI renders humanized status text for `herd` sentinels instead of raw error strings. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-07-16-front-end-thinning.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute both tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** + +After execution, record findings in the spec's §14.2 ("After Plan 2 — front-end thinning") before Plan 3, then offer `superpowers:finishing-a-development-branch`. diff --git a/docs/superpowers/plans/2026-07-16-session-canonical-compat.md b/docs/superpowers/plans/2026-07-16-session-canonical-compat.md new file mode 100644 index 0000000..3a076cf --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-session-canonical-compat.md @@ -0,0 +1,470 @@ +# Pre-`@codeherd_project` Session Compatibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Spec:** `docs/superpowers/specs/2026-07-16-session-canonical-compat-design.md`. Read §3 (principle), §4 (the three layers), and §5 (testing) before starting. + +**Goal:** Make the new binary recognize, kill, and heal tmux sessions that were created before the `@codeherd_project` option existed, by matching on the frozen `@codeherd_canonical_name` instead of a name rebuilt from `Ref` parts. + +**Architecture:** The stored `@codeherd_canonical_name` is the session's identity of record. Task 1 makes every match key on it (the correctness guarantee). Task 2 adds best-effort project recovery and a one-time self-heal, folded into the single `handles()` chokepoint so the logic is single-sourced, plus the doc corrections that describe the now-complete behaviour. + +**Tech Stack:** Go (module `github.com/xico42/codeherd`), tmux, stdlib `testing` with hand-written fakes at the `Runner` seam. + +## Global Constraints + +- **`make check` must pass before every commit.** It runs coverage (80% floor), integration tests, lint, and build. A task is not done until it is green. +- **Coverage floor: 80% aggregate.** New code carries tests in the same commit. +- **`herd` must never import `cmd` or `internal/tui`.** +- **Typed enums:** `SessionType` / `Status` stay defined types with named constants; no bare strings for them. +- **`wrapcheck` is enabled.** Every error crossing a package boundary is wrapped with `%w` and a context prefix. `_test.go` files are exempt. +- **`goimports` with `local-prefixes: github.com/xico42/codeherd`.** Import blocks are stdlib / third-party / codeherd. +- **All `herd` tests live in `package herd`** (internal), alongside the shared `fakes_test.go`. +- **This branch continues `chore/refactor-packages`** after the Plan 1 (herd collapse) commits. `internal/herd` already exists and is the only package touched here besides one comment in `internal/tmux` and the Plan 1 spec. + +## Background — the exact defect + +A session started by any pre-collapse binary carries `@codeherd_canonical_name` (e.g. `work-myapp-feat`), `@codeherd_profile`, `@codeherd_branch`, etc., but **not** `@codeherd_project` (added by the collapse). The new binary matches sessions by `hd.Ref.CanonicalName()`, which rebuilds the name from `Ref.{Profile,Project,Branch}`. With `Project == ""` that rebuild yields `work--feat` (empty project segment), which never equals the real stored `work-myapp-feat`. So the session is dropped from `List` and survives `StopSessions`/`Teardown` — an orphan. Matching on the stored canonical fixes it. + +## File Structure + +**Modified:** + +| File | Responsibility | +|---|---| +| `internal/herd/session.go` | `Handle.Canonical` field; `handleFrom` gains the field (Task 1) then becomes a method with recover+heal (Task 2); `resolveProject` helper (Task 2); `Resolve`/`StopSessions` match on the stored canonical (Task 1). | +| `internal/herd/workspace.go` | `List`'s join keys on the stored canonical (Task 1). | +| `internal/herd/session_test.go` | The compat regression test (Task 1); `resolveProject` unit tests + recover/heal/idempotence tests (Task 2). | +| `internal/tmux/client.go` | `SessionRecord.Project` doc comment corrected (Task 2). | +| `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md` | §14.1 behaviour #7 rewrite + drop the resolved Plan-2-inherits hardening note (Task 2). | + +--- + +### Task 1: Match live sessions on the stored canonical name + +The correctness guarantee. After this task, a pre-upgrade session (empty `Project`, real `Canonical`) is found by `Resolve`, killed by `StopSessions`, and joined by `List` — even though its project is still blank. No recovery or healing yet. + +**Files:** +- Modify: `internal/herd/session.go` (`Handle` struct at 15-23; `handleFrom` at 296-308; `Resolve` at 183; `StopSessions` at 220 and 226) +- Modify: `internal/herd/workspace.go` (`List` join key at 262) +- Test: `internal/herd/session_test.go` + +**Interfaces:** +- Consumes: `tmux.SessionRecord.CanonicalName` (existing); `Ref.CanonicalName()` (existing). +- Produces: + ```go + // Handle gains one field: + type Handle struct { + ID string + Canonical string // @codeherd_canonical_name — frozen identity, the match key + Ref Ref + Type SessionType + TmuxName string + Status Status + Annotation string + StartedAt time.Time + } + ``` + `Resolve`, `StopSessions`, and `List`'s join all match on `hd.Canonical`. + +- [ ] **Step 1: Write the failing regression test** + +Add to `internal/herd/session_test.go`: + +```go +// A session created before @codeherd_project existed has a correct stored +// canonical name but an empty Project. It must still be found and killed — +// this is the exact orphan the collapse reintroduced. +func TestStopSessions_preUpgradeSession_matchedByStoredCanonical(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: ""}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: t.TempDir()}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + if _, err := h.Resolve(h.Ref("myapp", "feat"), SessionTypeAgent); err != nil { + t.Fatalf("Resolve found nothing for a pre-upgrade session: %v", err) + } + if _, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{}); err != nil { + t.Fatalf("StopSessions: %v", err) + } + if got := f.killed(); len(got) != 1 || got[0] != "$1" { + t.Errorf("killed = %v, want [$1] — the pre-upgrade session was not killed", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/herd/ -run TestStopSessions_preUpgradeSession_matchedByStoredCanonical -v` +Expected: FAIL — `Resolve found nothing` (the rebuilt name `work--feat` does not match the stored `work-myapp-feat`). + +- [ ] **Step 3: Add the `Canonical` field to `Handle`** + +In `internal/herd/session.go`, change the `Handle` struct (15-23) to add the field: + +```go +type Handle struct { + ID string // tmux session_id ("$1") — stable across renames + Canonical string // @codeherd_canonical_name — the frozen identity, the match key + Ref Ref + Type SessionType + TmuxName string // current tmux name; may carry the ⚡ status prefix + Status Status + Annotation string + StartedAt time.Time +} +``` + +- [ ] **Step 4: Populate `Canonical` in `handleFrom`** + +In `handleFrom` (session.go:296), add the field to the struct literal: + +```go +func handleFrom(r tmux.SessionRecord) Handle { + hd := Handle{ + ID: r.ID, + Canonical: r.CanonicalName, + Ref: Ref{Profile: r.Profile, Project: r.Project, Branch: r.Branch}, + Type: SessionType(r.SessionType), + TmuxName: r.Name, + Status: Status(r.Status), + Annotation: r.Annotation, + } + if r.StartedAt != "" { + hd.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) + } + return hd +} +``` + +- [ ] **Step 5: Match on `hd.Canonical` in `Resolve` and `StopSessions`** + +In `Resolve` (session.go:183), change the match: + +```go + for _, hd := range all { + if hd.Canonical == canonical && hd.Type == t { + return hd, nil + } + } +``` + +In `StopSessions` (session.go:220 and the error at 226), change both: + +```go + for _, hd := range all { + if hd.Canonical != canonical { + continue + } + if !opts.All && hd.Type != opts.Type { + continue + } + if err := h.tmux.KillSession(hd.ID); err != nil { + return stopped, fmt.Errorf("killing session %s: %w", hd.Canonical, err) + } + stopped = append(stopped, hd) + } +``` + +- [ ] **Step 6: Key the `List` join on `hd.Canonical`** + +In `internal/herd/workspace.go`, change the join key (262) only. The lookup at 282-283 stays keyed on `ws.Ref.CanonicalName()` (the workspace always has a complete, real-project `Ref`, so its rebuilt name is correct): + +```go + byName := make(map[string][]Handle, len(sessions)) + for _, hd := range sessions { + key := hd.Canonical + byName[key] = append(byName[key], hd) + } +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `go test ./internal/herd/ -run TestStopSessions_preUpgradeSession_matchedByStoredCanonical -v` +Expected: PASS. + +Then run the whole herd package to confirm no existing match test regressed (new sessions have `Canonical == Ref.CanonicalName()`, so the switch is a no-op for them): +Run: `go test ./internal/herd/` +Expected: PASS. + +- [ ] **Step 8: Verify and commit** + +Run: `make check` +Expected: green, ≥80%. + +```bash +git add internal/herd/session.go internal/herd/workspace.go internal/herd/session_test.go +git commit -m "fix: match live sessions on the stored canonical name + +Resolve, StopSessions, and List's join keyed on a name rebuilt from Ref +parts, so a session created before the @codeherd_project stamp (empty +project segment) never matched its real stored name and survived a +delete. Match on the frozen @codeherd_canonical_name instead — the +identity every prior version used. No-op for sessions created by this +binary, where the stored and rebuilt names are identical." +``` + +--- + +### Task 2: Recover the project and self-heal, and correct the docs + +Best-effort recovery for display, plus a one-time re-stamp so the session becomes first-class, plus the doc corrections that now describe the complete behaviour. All recovery/heal lives in the single `handles()` chokepoint via `h.handleFrom`, so every read path inherits it with no duplication. + +**Files:** +- Modify: `internal/herd/session.go` (add `resolveProject`; `handleFrom` becomes method `h.handleFrom` with recover+heal; `handles` at 290 calls `h.handleFrom`) +- Modify: `internal/tmux/client.go` (`SessionRecord.Project` comment) +- Modify: `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md` (§14.1) +- Test: `internal/herd/session_test.go` + +**Interfaces:** +- Consumes: `Handle.Canonical` (Task 1); `semconv.SessionName` (existing); `h.cfg`, `h.tmux`, `semconv.TmuxOptionProject` (existing). +- Produces: + ```go + // Pure, unambiguous project recovery from the frozen name: + func resolveProject(cfg *config.Config, profile, branch, canonical string) (string, bool) + + // handleFrom is now a method so it can read cfg and stamp the heal: + func (h *Herd) handleFrom(r tmux.SessionRecord) Handle + ``` + +- [ ] **Step 1: Write the failing `resolveProject` unit tests** + +Add to `internal/herd/session_test.go`: + +```go +func TestResolveProject(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{ + "myapp": {}, "other": {}, + }} + tests := []struct { + name string + profile, branch string + canonical string + wantProj string + wantOK bool + }{ + {"under profile", "work", "feat", "work-myapp-feat", "myapp", true}, + {"no profile", "", "feat", "myapp-feat", "myapp", true}, + {"flattened slash branch", "work", "feat/login", "work-myapp-feat-login", "myapp", true}, + {"no configured match", "work", "feat", "work-nope-feat", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := resolveProject(cfg, tt.profile, tt.branch, tt.canonical) + if got != tt.wantProj || ok != tt.wantOK { + t.Errorf("resolveProject = (%q, %v), want (%q, %v)", got, ok, tt.wantProj, tt.wantOK) + } + }) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/herd/ -run TestResolveProject -v` +Expected: FAIL — `undefined: resolveProject`. + +- [ ] **Step 3: Add the `resolveProject` helper** + +In `internal/herd/session.go`, add above `handleFrom`: + +```go +// resolveProject finds the configured project whose canonical session name +// matches the stored one, given the (stored) profile and branch. Profile and +// branch are known exactly, so the project is the only unknown and the match +// is unambiguous. It validates against real config rather than string- +// splitting the name, so a project no longer in config yields "", false. +func resolveProject(cfg *config.Config, profile, branch, canonical string) (string, bool) { + for name := range cfg.Projects { + if semconv.SessionName(profile, name, branch) == canonical { + return name, true + } + } + return "", false +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/herd/ -run TestResolveProject -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing recover-and-heal tests** + +Add to `internal/herd/session_test.go`: + +```go +// A pre-upgrade session's project is recovered for display and re-stamped on +// the live session, so it heals to first-class on first observation. +func TestSessions_preUpgradeSession_recoversAndHealsProject(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: ""}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: t.TempDir()}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + sessions, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions: %v", err) + } + if len(sessions) != 1 || sessions[0].Ref.Project != "myapp" { + t.Fatalf("Ref.Project = %q, want %q", sessions[0].Ref.Project, "myapp") + } + if !f.called("set-option", "@codeherd_project", "myapp") { + t.Errorf("project was not re-stamped; calls=%v", f.Calls) + } +} + +// A session that already carries @codeherd_project is never re-stamped. +func TestSessions_stampedSession_isNotHealed(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: t.TempDir()}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + if _, err := h.Sessions(); err != nil { + t.Fatalf("Sessions: %v", err) + } + if f.called("set-option", "@codeherd_project") { + t.Errorf("an already-stamped session was healed again; calls=%v", f.Calls) + } +} +``` + +- [ ] **Step 6: Run the tests to verify they fail** + +Run: `go test ./internal/herd/ -run 'TestSessions_preUpgradeSession_recoversAndHealsProject|TestSessions_stampedSession_isNotHealed' -v` +Expected: FAIL — `Ref.Project = "", want "myapp"` (recovery not implemented; `handleFrom` still leaves the empty project as-is). + +- [ ] **Step 7: Make `handleFrom` a method that recovers and heals** + +In `internal/herd/session.go`, replace the `handleFrom` function with a method: + +```go +func (h *Herd) handleFrom(r tmux.SessionRecord) Handle { + hd := Handle{ + ID: r.ID, + Canonical: r.CanonicalName, + Ref: Ref{Profile: r.Profile, Project: r.Project, Branch: r.Branch}, + Type: SessionType(r.SessionType), + TmuxName: r.Name, + Status: Status(r.Status), + Annotation: r.Annotation, + } + if r.StartedAt != "" { + hd.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) + } + + // Backward compatibility: sessions created before @codeherd_project existed + // carry no project stamp. Recover it from the frozen canonical name and + // stamp it, so the session heals to first-class on first observation. + // Idempotent — once stamped, future reads take r.Project directly and skip + // this path. + if r.Project == "" && r.CanonicalName != "" { + if project, ok := resolveProject(h.cfg, r.Profile, r.Branch, r.CanonicalName); ok { + hd.Ref.Project = project + _ = h.tmux.SetOption(r.Name, semconv.TmuxOptionProject, project) + } + } + return hd +} +``` + +- [ ] **Step 8: Call the method from `handles`** + +In `handles` (session.go:290), change the call: + +```go + for _, r := range records { + if r.CanonicalName == "" { + continue // not a codeherd session + } + out = append(out, h.handleFrom(r)) + } +``` + +- [ ] **Step 9: Run the recover-and-heal tests to verify they pass** + +Run: `go test ./internal/herd/ -run 'TestSessions_preUpgradeSession_recoversAndHealsProject|TestSessions_stampedSession_isNotHealed' -v` +Expected: PASS. + +Then the whole package: +Run: `go test ./internal/herd/` +Expected: PASS. + +- [ ] **Step 10: Correct the `SessionRecord.Project` comment** + +In `internal/tmux/client.go`, replace the `Project` field comment (the block ending in the "fails loudly" claim) with: + +```go + // Project is @codeherd_project — the project the session belongs to, "" + // when unset. Sessions started before this option existed have no value + // here; internal/herd recovers the project from the frozen canonical name + // and re-stamps it on first observation (see herd.resolveProject), so such + // a record heals to first-class rather than being orphaned. + Project string +``` + +- [ ] **Step 11: Correct the Plan 1 handoff — spec §14.1** + +In `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md`, rewrite behaviour change #7 (currently describing pre-upgrade sessions as dropped / surviving teardown) to: + +```markdown +7. **Sessions created before this release (missing the `@codeherd_project` stamp) are recognized, listed, killed, and healed automatically.** The domain matches live sessions on the frozen `@codeherd_canonical_name` (the identity of record every prior version used), not on a name rebuilt from `Ref` parts, so a missing project can no longer hide a session from the TUI or a teardown. On first observation the missing project is recovered from the canonical name and re-stamped, healing the session to first-class. No orphans, no manual migration. +``` + +Then delete the "What Plan 2 inherits" bullet that begins "**Pre-upgrade sessions could be recognized instead of silently dropped (behaviour change #7).**" — it is implemented here, no longer inherited. + +- [ ] **Step 12: Verify and commit** + +Run: `make check` +Expected: green, ≥80%. + +```bash +git add internal/herd/session.go internal/herd/session_test.go internal/tmux/client.go docs/superpowers/specs/2026-07-15-herd-domain-package-design.md +git commit -m "feat: recover and self-heal the project on pre-upgrade sessions + +A session created before the @codeherd_project stamp carries no project, +so its Ref rendered blank and could not be re-stamped. Recover the +project by matching the frozen canonical name against configured +projects, then re-stamp it on first observation so the session heals to +first-class. Recovery and healing live in the one handles() chokepoint +every read path funnels through, so the logic is single-sourced. + +Corrects the SessionRecord.Project comment and Plan 1 handoff #7, which +described a 'fails loudly' guard the code never actually reached." +``` + +--- + +## Self-Review + +**Spec coverage:** +- §3 principle (match on stored canonical) → Task 1 Steps 3-6. +- §4 Layer 1 (guarantee) → Task 1. Layer 2 (`resolveProject`) → Task 2 Steps 1-4. Layer 3 (self-heal with compat comment) → Task 2 Step 7. Reusability (single `handles()` chokepoint, `handleFrom` method) → Task 2 Steps 7-8. +- §5 testing: compat regression → Task 1 Step 1; `resolveProject` units → Task 2 Step 1; recover+heal+idempotence → Task 2 Step 5. tmux isolation is not needed — every test uses `fakeTmux`, no real tmux server. +- §6 docs (SessionRecord comment; §14.1 #7; drop the inherited hardening note) → Task 2 Steps 10-11. +- §7 boundary (project removed from config → lists blank, still killable): covered by construction — Task 1's match works without recovery, and `resolveProject` returning `false` (Task 2 Step 1 `no configured match` case) leaves `Ref.Project` blank with no heal write. + +**Placeholder scan:** none — every step carries the actual code or command. + +**Type consistency:** `Handle.Canonical` (Task 1 Step 3) is read in Task 1 Steps 5-6 and populated in Task 2 Step 7's method. `resolveProject(cfg, profile, branch, canonical)` signature (Task 2 Step 3) matches its call in Task 2 Step 7 and its tests in Step 1. `h.handleFrom` (Task 2 Step 7) matches its call in Step 8. `semconv.TmuxOptionProject` is the existing `"@codeherd_project"` constant asserted in the Step 5 tests via `f.called("set-option", "@codeherd_project", "myapp")`. + +**Note for the executor:** Task 1 leaves `handleFrom` a free function; Task 2 converts it to a method. Do not merge the two — Task 1 is the independently reviewable correctness guarantee (matching), Task 2 is the recovery/heal layer on top. diff --git a/docs/superpowers/specs/2026-07-15-herd-domain-package-design.md b/docs/superpowers/specs/2026-07-15-herd-domain-package-design.md new file mode 100644 index 0000000..86b2400 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-herd-domain-package-design.md @@ -0,0 +1,603 @@ +# `herd` domain package — design + +**Status:** proposed (brainstorming) +**Date:** 2026-07-15 +**Out of scope:** the build-time plugin/extension model (no clear model yet — see §11); `filecopy` and `herdtemplate` internals; the TUI's visual design. + +## 1. Goal + +Collapse `internal/session`, `internal/worktree`, and `internal/project` into one domain package, `internal/herd`, that owns both the primitives and the multi-step operations built on them. Reduce `cmd/` and `internal/tui/` to thin front ends that parse input, call one `herd` operation, and render the result. + +This eliminates a class of defects, not a single defect. The bug that prompted the work — a deleted worktree leaving its agent's tmux session and process tree alive — is one of at least four instances of the same root cause shipping today. + +## 2. Background: the defect and what it revealed + +`internal/tui/actions.go`'s `confirmDeleteAll` killed the shell session by its tmux session ID and the agent session by a *rebuilt session name*. The rebuilt name missed, the error was discarded with `_ =`, and the worktree was force-deleted anyway — orphaning the agent process against a directory that no longer existed. + +The name missed for two independent reasons: + +1. **Diverged HEAD.** `Stop` rebuilt the name from `Item.Branch`, which holds the *display* branch. `items.go:88-108` deliberately lets that differ from the identity branch the session was named after (introduced by b51f560). +2. **Active profile.** `session.Service.Stop` hardcodes an empty profile via `semconv.SessionName("", …)`, so under a profile it searches for `myapp-feat` while the session is `work-myapp-feat`. + +The fix on this branch kills both sessions by tmux session ID. It resolves the symptom in one function and leaves the cause untouched. + +### 2.1 The structural cause + +Compare the three constructors: + +```go +func worktree.NewService(cfg *config.Config, git WorktreeRunner, tmux *tmux.Client, hook hooks.Hook) +func project.NewService(cfg *config.Config, git GitRunner, hook hooks.Hook) +func session.NewService(tmux *tmux.Client, hook hooks.Hook) // no cfg +``` + +`session.Service` is the only core service without config. It cannot know the active profile. So every profile decision moved up into its callers, and the API records the asymmetry: + +```go +type StartRequest struct { …; Profile string } // the write path takes a profile +func (s *Service) Start(req StartRequest) (string, error) + +func (s *Service) Show(project, branch, sessionType string) (*SessionInfo, error) // no profile +func (s *Service) Stop(project, branch, sessionType string) error // no profile +``` + +**You can create a session you cannot address.** No call site could have gotten this right; the parameter does not exist. `ShowByName`/`StopByName` are the escape hatch someone added later, and `cmd/services.go` exists only to choose between the two variants: + +```go +prof := activeProfile() +if prof == "" { + return svc.Show(project, branch, sessionType) +} +return svc.ShowByName(semconv.SessionName(prof, project, branch), sessionType) +``` + +That shim is the missing `cfg` field in disguise. + +### 2.2 The boundary is already broken + +`internal/worktree` imports `internal/tmux` and uses it for exactly three things, all of them session operations: + +```go +worktree.go:594: if running, _ := s.tmux.HasSession(candidate); running { +worktree.go:636: running, err := s.tmux.HasSession(name) +worktree.go:644: if err := s.tmux.KillSession(name); err != nil { +``` + +It also declares `ErrSessionRunning` and builds session names with `semconv.SessionName`. **The worktree package manages sessions.** Nothing prevented it from importing `session` (no cycle exists), but it reimplemented the logic against the raw tmux client instead — without the profile, and therefore wrongly. + +The domain object is a *workspace*: a worktree together with its sessions. Splitting it across packages that cannot see each other forced one to smuggle the other's logic in through the mechanism layer. The enforced boundary caused the defect. + +## 3. Evidence: the duplication this creates + +Counts are non-test occurrences. + +| Pattern | Count | Locations | +|---|---|---| +| `semconv.SessionName("", …)` — the profile-blind literal | 9 | `cmd/template.go:83`, `cmd/worktree.go:157,179`, `worktree.go:593,631,632`, `session.go:206,295`, `tui/actions.go:471` | +| `ListSessions()` + match on `CanonicalName` + `SessionType` | 6 | `session.go:97,212,242,274,302,330` | +| `tmux.NewClient(tmux.NewRealRunner())` | 10 | 8 in `cmd/`, plus `tui/`, `plugin.go` | +| `h := hooks.New(projCfg.Hooks)` — per-project hook binding | 13 | 5 in `cmd/` (`session.go:192`, `template.go:72`, `worktree.go:105`, `project.go:108,135`); 8 in `internal/tui/` (`actions.go:65,107,200,231,263,410`, `agent_picker.go:94`, `form.go:137`) | +| "clone → worktree → copy → template → session" chain | 3 | `cmd/worktree.go:112-198`, `cmd/session.go:194-266`, `tui/actions.go` (×4 via `runFileCopyAndTemplate`) | + +### 3.1 Live defects beyond the one fixed + +| Defect | Evidence | +|---|---| +| `.herd` templates render a different `SessionName` depending on which command created the worktree | `cmd/session.go:233` passes `activeProfile()`; `cmd/worktree.go:157`, `cmd/template.go:83`, `tui/actions.go:471` pass `""` | +| `ch list worktree`'s "(running)" marker never appears under a profile | `worktree.go:593` hardcodes `""` | +| The kill loop in `worktree.Delete` is always dead code | The TUI kills by ID first; `Delete:635` then re-runs a profile-less lookup that either misses (profile on) or no-ops (profile off) | + +### 3.2 Dependency injection already collapsed + +`cmd/tui.go:117-121` injects three services built with `&hooks.NoOp{}`. The actions need `hooks.New(projCfg.Hooks)`, so every action rebuilds its own service — 8 times inside `internal/tui` alone. `Model.sesSvc` and `Model.projSvc` are **assigned and never read**: dead fields. Per-project hook binding defeated injection. + +This is the constraint that decides §6: `Herd` can hold pre-built state only because it holds `cfg` and resolves hooks per operation itself. Any design that binds hooks at construction reproduces these dead fields. + +### 3.3 The test that should have caught it + +`cmd/profiles_integration_test.go:136`, `TestProfiles_sessionIsolationAcrossProfiles`, runs against real tmux and covers profile × {create, list}. It never stops or deletes anything. The bug lives in profile × {stop, delete, show} — the quadrant nobody wrote. + +## 4. Decision + +Merge `internal/session`, `internal/worktree`, and `internal/project` into `internal/herd`. Keep the exec-boundary interfaces that already exist. Front ends depend only on `herd`. + +The rule that decides membership: + +> **Domain** = needs `cfg`, the profile, or identity to make a decision → `herd`. +> **Mechanism** = does not → stays a support package. + +`filecopy` and `herdtemplate` stay out. They never needed the profile, which is why they were never implicated. + +## 5. Package structure + +``` +main.go thin — unchanged (three lines) +cmd/ front end + composition root +internal/tui/ front end +internal/herd/ DOMAIN — session.go worktree.go project.go launch.go teardown.go list.go +internal/tmux/ mechanism — Runner interface + Client +internal/git/ mechanism — WorktreeRunner + CloneRunner, rehoused from two packages +internal/config/ internal/semconv/ internal/hooks/ internal/filecopy/ internal/herdtemplate/ +``` + +`cmd/` remains outside `internal/` and `main.go` remains thin. Both are deliberate and unchanged. + +`herd` must never import `cmd` or `tui`. That discipline costs nothing now and keeps a future promotion of `herd` out of `internal/` a rename. + +`internal/git` rehouses the two git-exec abstractions that live in separate packages today. Both interfaces keep their current shape: + +```go +package git + +type WorktreeRunner interface { … } // 13 methods — was worktree.WorktreeRunner +type CloneRunner interface { Clone(repo, path, branch string) error } // was project.GitRunner +type Runner interface { WorktreeRunner; CloneRunner } // wiring convenience + +type RealRunner struct{} // implements Runner +``` + +This rehouses rather than redesigns. `WorktreeRunner` is already a 13-method interface; widening `Deps.Git` to the `Runner` union means a fake must satisfy all 14, so `herd`'s test package carries one shared fake with per-test overrides rather than each test hand-rolling a runner. Splitting these interfaces further is out of scope — they sit at the exec boundary where exactly one real implementation exists. + +## 6. The `herd` API + +Inside one cohesive package, `session.go` calls `worktree.go` directly. No internal interfaces. The only interfaces are `tmux.Runner`, the git runners, and `hooks.Hook` — all pre-existing, all at the exec boundary. + +Only the exported surface is a design commitment: + +```go +package herd + +// ── types ── +type Herd struct{ … } // cfg + profile + runners +type Ref struct{ Profile, Project, Branch string } // Branch is ALWAYS the identity branch +type Handle struct{ ID string; Ref Ref; Type SessionType; Status, Annotation string } +type Workspace struct { + Ref Ref + Path string + IsMain bool + DisplayBranch string // derived for rendering + HeadHint string // "detached" | "on " + Agent, Shell *Handle // nil when not running +} +type Project struct{ … } +type RemoteBranch struct{ … } +type SessionType string // SessionTypeAgent | SessionTypeShell + +type Deps struct{ Tmux tmux.Runner; Git git.Runner } +type EnsureOpts, LaunchOpts, StopOpts, TeardownOpts struct{ … } + +// ── constructor ── +func New(cfg *config.Config, profile string, deps Deps) *Herd + +// ── identity ── +func (h *Herd) Ref(project, branch string) Ref // supplies the profile +func (h *Herd) WithProfile(name string) (*Herd, error) + +// ── query ── +func (h *Herd) List(project string) ([]Workspace, error) // "" = all projects +func (h *Herd) Resolve(ref Ref, t SessionType) (Handle, error) +func (h *Herd) Projects() []Project +func (h *Herd) RemoteBranches(project string) ([]RemoteBranch, error) + +// ── mutate ── +func (h *Herd) EnsureWorkspace(ref Ref, opts EnsureOpts) (Workspace, error) +func (h *Herd) Launch(ref Ref, opts LaunchOpts) (Handle, error) +func (h *Herd) StopSessions(ref Ref, opts StopOpts) ([]Handle, error) +func (h *Herd) Teardown(ref Ref, opts TeardownOpts) error +func (h *Herd) Clone(project string) error +func (h *Herd) Provision(ref Ref) error // `ch template` +func (h *Herd) SetStatus(sessionName, status, annotation string) error + +// ── errors ── +var ErrNotCloned, ErrAlreadyCloned, ErrWorktreeNotFound, ErrWorktreeExists, + ErrSessionNotFound, ErrSessionRunning error +type SessionExistsError struct{ … } +``` + +`hooks` does not appear. `Herd` holds `cfg`, so it builds `hooks.New(cfg.Projects[p].Hooks)` per operation itself. Tests pass a `cfg` with no hooks configured and nothing fires. + +### 6.1 `Ref` and the convention + +`Ref` has exported fields. The gate is convention: **obtain a `Ref` from `h.Ref(…)` or from `Workspace.Ref`, never by hand.** + +This convention is stronger than the one it replaces. `SessionName("", p, b)` failed nine times because the profile was a positional string where `""` is easy to type, legitimately valid, and the only available path. `h.Ref(p, b)` takes no profile at all, so the shortest path is the correct one, and a hand-built `herd.Ref{Project: p, Branch: b}` is visibly missing a field under review. + +An opaque `Ref` (unexported fields) was considered and rejected in §11. + +### 6.2 `Workspace` separates identity from display + +`Ref.Branch` is identity. `DisplayBranch` is derived for rendering. Today both are the same `string` in `Item.Branch`, which is exactly how a rendering value round-tripped into `wtSvc.Delete`. Here no path back exists: `Teardown` takes a `Ref`, and a `DisplayBranch` will not compile into one. + +`Workspace` also collapses a duplicated join. `worktree.Service.List` computes identity and a session name, then discards both into the display string `"proj-branch (running)"` (`worktree.go:40`), forcing `items.go` to recompute the same join with the correct profile. One `List` returns structure both front ends consume. + +## 7. Operations + +`herd` owns only flows that are multi-step and duplicated across front ends today. Single-step verbs stay direct calls. **A `herd` method that merely forwards is wrong.** + +| Operation | Steps | +|---|---| +| `EnsureWorkspace` | clone (optional) → create worktree if missing → provision (filecopy + templates) | +| `Launch` | resolve path → resolve agent → start session → attach (optional) | +| `StopSessions` | list handles matching `ref` → stop each **by ID** | +| `Teardown` | `StopSessions` → delete worktree | +| `List` | list worktrees → list sessions → join on `Ref` | +| `Resolve` | find the live handle for `ref` + type | + +`EnsureWorkspace` and `Launch` stay separate. `ch create session` calls both. Two lines of composition per front end beats a `LaunchOpts` carrying a worktree-creation sub-struct, and the TUI needs `EnsureWorkspace` alone for its create-worktree form. + +### 7.1 Every divergence becomes a named argument + +`herd` owns mechanics; front ends pass policy explicitly. Behaviours that differ between CLI and TUI today become visible arguments rather than being silently reconciled. + +| Divergence today | Named as | CLI | TUI | +|---|---|---|---| +| TUI auto-clones on attach; CLI never does | `EnsureOpts.AutoClone` | `false` | `true` | +| TUI hardcodes `Force: true`; CLI honours `ErrSessionRunning` | `TeardownOpts.Force` | `--force` | `true` | +| `--from` / `--track` | `EnsureOpts.StartPoint` / `.Track` | flags | form fields | +| `--shell` | `StopOpts.Type` | flag | menu choice | +| `--attach` | `LaunchOpts.Attach` | flag | always | + +## 8. Data flow + +### 8.1 Composition root + +The only place any service is constructed: + +```go +// cmd/root.go +var h *herd.Herd // replaces the cfg + registry globals + +PersistentPreRunE: func(c *cobra.Command, args []string) error { + cfg, registry, err := config.Load(cfgFile, resolveProfileArg(profileFlag)) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + h = herd.New(cfg, registry.Active, herd.Deps{ + Tmux: tmux.NewRealRunner(), + Git: git.NewRealRunner(), + }) + return nil +} +``` + +`cmd/tui.go` passes `h` to `tui.NewModel` instead of three services. The TUI's `profileCache` of rebuilt service bundles (`model.go:605-626`) becomes `h.WithProfile(name)`. + +### 8.2 `ch create session myapp feat --agent claude` + +Today: `cmd/session.go:160-285`, roughly 125 lines, with copy and template blocks near-verbatim duplicated from `cmd/worktree.go:133-160`. After: + +```go +func (c *CreateSessionCmd) Run(cmd *cobra.Command, args []string) error { + ref := h.Ref(args[0], args[1]) + + if _, err := h.EnsureWorkspace(ref, herd.EnsureOpts{ + AutoClone: false, // CLI never auto-clones — previously implicit, now stated + Provision: true, + }); err != nil { + return herdErr(cmd, err) + } + + handle, err := h.Launch(ref, herd.LaunchOpts{Type: c.sessionType(), Agent: agentName, Attach: c.Attach}) + if err != nil { + return herdErr(cmd, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Started %s\n", handle.Ref.CanonicalName()) + return nil +} +``` + +The template `SessionName` divergence dies here: `Provision` builds `ProcessContext` from `ref` in one place, so no site can pass `""` while another passes the profile. + +### 8.3 The TUI delete that started this + +```go +func (m Model) confirmDeleteAll() (tea.Model, tea.Cmd) { + ref := m.confirm.target.Ref // identity from herd.List — never a display string + h := m.herd + m.confirm, m.screen = nil, screenList + + return m, func() tea.Msg { + if err := h.Teardown(ref, herd.TeardownOpts{Force: true}); err != nil { + return errMsg{err: err} + } + return m.refreshCmd()() + } +} +``` + +`Teardown` lists handles by `ref`, stops each by ID, then deletes the worktree. One kill loop, profile-correct by construction. Today there are two, and one is always dead. + +### 8.4 TUI refresh + +`refreshCmd` (~100 lines, `model.go:462-559`) and `buildItems` (~95 lines, `items.go:73-168`) collapse to `h.List("")`. `items.go` stops deriving identity and becomes a mapping from `Workspace` to render rows. + +### 8.5 Expected scale + +| File | Now | After (est.) | +|---|---|---| +| `internal/tui/actions.go` | 477 | ~250 | +| `internal/tui/model.go` | 695 | ~570 | +| `cmd/session.go` | 376 | ~200 | +| `cmd/worktree.go` | 250 | ~150 | +| `internal/herd/*` | — | ~1,550 | + +Net: roughly −600 lines in the front ends; `herd` absorbs ~1,200 lines of existing logic plus ~350 of orchestration. + +## 9. Error handling + +**One vocabulary.** All sentinels move to `herd`. Today they span three packages, and `cmd/errors.go` has two translators that both handle `worktree.ErrNotCloned` while printing different text for it (`errors.go:16` vs `:44`). One package yields one translator. + +**Fix the `os.Exit` wart.** `cmd/errors.go` calls `os.Exit(1)` inside `RunE`, making the trailing `return nil` unreachable and bypassing `Execute`'s error printing (`root.go:74-77`). Call sites read `return worktreeErr(cmd, …)` but never return. It becomes a translator that returns an error and lets Cobra exit. + +**The TUI stops rendering raw errors.** `internal/tui` contains no `errors.Is` today; it prints `msg.err.Error()` where the CLI prints "Run `ch clone project X` first". One vocabulary lets the TUI match the same sentinels. + +`herd` never formats user-facing text. Presentation stays in each front end. + +## 10. Testing + +**Existing tests migrate largely intact.** `session` (91%) and `worktree` (89.3%) already mock at the `Runner` seam per `CLAUDE.md`, and the collapse does not move that seam. Coverage carries across rather than being rewritten — this is what makes the restructure tractable. + +**Unit.** `herd` tests fake `tmux.Runner` and the git runners. No service-level mocks: the layer that would be mocked is now the code under test. + +**Integration — the coverage contract.** Every operation runs with profiles on *and* off. This matrix is the gate that would have caught the original defect. + +| | profile off | profile on | +|---|---|---| +| `EnsureWorkspace` | covered | covered | +| `Launch` | covered | covered | +| `List` | covered | covered | +| `StopSessions` | covered | **gap today** | +| `Teardown` | covered | **gap today** | +| `Resolve` | covered | **gap today** | + +The two regression tests on this branch (`internal/tui/delete_teardown_test.go`) carry forward against `Teardown`. + +`make check` — 80% coverage floor, integration, lint, build — gates every stage. + +## 11. Decisions and rejected alternatives + +| Decision | Rejected alternative | Why | +|---|---|---| +| Collapse the primitives into `herd` | Add `herd` as an orchestration layer *above* `session`/`worktree`/`project` | The layered version preserves the boundary that caused the defect. It also required nine consumer-defined interfaces, a `hooks.NewResolver` dispatcher, and forcing `Ref` into `semconv` to dodge adapter types — all accidental complexity defending a layer that should not exist. | +| Fold `project` in too | Keep `project` out (it never imports `tmux`) | Symmetry and one error vocabulary. `project` shows no entanglement, so this is the weaker half of the decision; it is reversible. | +| `Ref` with exported fields + convention | Opaque `Ref` (unexported fields, compiler-enforced) | Opaque fields force every TUI render test to build a `Herd` with fake runners just to mint a `Ref`. That ceremony suppresses tests. The convention is also much stronger than the one that failed (§6.1). | +| `herd.Herd` | `herd.Env` | "Env" reads as environment variables. The stutter rule targets redundant prefixes (`http.HTTPServer`), not a package's central type — cf. `time.Time`, `url.URL`, `template.Template`. Call sites read `h := herd.New(…); h.Teardown(…)`. | +| `Deps` struct | Functional options | Two fields. Revisit at three. | +| ~13 methods on `Herd` | Split into several types | Accepted with reservation: roughly one method per CLI verb. Revisit if it smells in practice. | +| `internal/herd` | `herd/` (importable) | No extension model exists yet (YAGNI). `internal` → public is a rename; public → `internal` breaks downstream builds. Starting internal keeps the door open at zero cost. | +| `SetStatus(name, …)` addresses by name | Address by `Ref` | `plugin handle-claude` receives a bare canonical name from `$CODEHERD_SESSION` and cannot recover a `Ref`: the profile prefix is ambiguous (`work-myapp-feat` could be profile `work` + project `myapp`, or a project literally named `work-myapp`). One narrow escape hatch beats re-exporting name resolution. | + +## 12. Implementation plans + +The work splits into **three plans, written and executed in order, each in its own session**. Each delivers working software and ends with `make check` green. Written to full granularity in one document, the whole refactor runs past 2,000 lines and 30 tasks — too large to review carefully, and the later stages depend on what the earlier ones learn. + +Each plan lands at `docs/superpowers/plans/YYYY-MM-DD-.md`. **Record what you learn in §14 before writing the next plan** — that section is the handoff between sessions. + +| Plan | Status | Scope | Done when | +|---|---|---|---| +| **1 — the collapse** | done | Create `internal/herd` and `internal/git`. Move project, then session, then worktree logic in. Delete `internal/project`, `internal/session`, `internal/worktree`. Add `Ref`, `Herd`, `Deps`, `New`, and the six operations. Migrate callers per domain as each moves. | The three packages are gone, `cmd`/`tui` compile against `herd`, the defect is dead structurally, coverage holds at ≥80% | +| **2 — front-end thinning** | not started | Reduce `cmd/` and `internal/tui/` to parse → call → render. Delete `cmd/services.go`'s `*ForProfile` shims, the dead `Model.sesSvc`/`Model.projSvc` fields, and the `profileCache`. Collapse `cmd/errors.go` to one translator and fix the `os.Exit`-inside-`RunE` wart (§9). Teach the TUI to match sentinels instead of printing raw errors. | ~600 fewer lines across the front ends; no front end constructs a service or builds a session name | +| **3 — the coverage contract** | not started | Fill the profile × operation integration matrix (§10). | The three gap cells — `StopSessions`, `Teardown`, `Resolve` under an active profile — are covered | + +### 12.1 Build order within Plan 1 + +**Build order is project → session → worktree**, which reverses the order these packages are listed elsewhere in this document. Dependencies decide it: + +- `project` has none — it never imports `tmux`. It moves first and cleanest. +- `session` needs `Ref` only. +- `worktree` needs both: `Teardown` calls the session code, and `EnsureWorkspace`'s `AutoClone` calls `Clone`. + +**The shipped defect dies at the end of the worktree stage**, once `Delete` sits beside the session code and can call it directly with the profile in hand. That is roughly two thirds of the way through Plan 1, not at the end of the project. + +Migrate callers as part of each domain's stage rather than deferring migration to Plan 2. Otherwise `cmd`/`tui` get touched twice per domain — once to rename an import, once to migrate for real — and the intermediate state needs temporary exported service types that exist only to be deleted. + +Stages are pure moves wherever possible: the existing `session` (91%) and `worktree` (89.3%) tests already mock at the `Runner` seam, which does not move, so they carry across rather than being rewritten. **A stage that rewrites tests instead of moving them is a signal the stage is doing too much.** + +## 13. Risks + +- **Scope.** Roughly 1,200 lines of logic plus their tests move. This restructures the core of a daily-driver tool. Mitigated by staging and by tests that move rather than get rewritten. +- **Package size.** `herd` becomes the largest package at ~1,550 lines across ~6 files. Nothing inside it enforces boundaries; discipline replaces the compiler. Accepted, because the enforced boundary we have today is what caused the defect. +- **`project` folding is the weakest link.** It shows no entanglement. If `herd` grows unwieldy, extracting it again is the first cut to consider. + +## 14. Handoff notes + +**This section is the handoff between sessions.** Each plan is written and executed in a fresh session that has only this document as context. Record here anything discovered during execution that changes what a later plan should do. Append; do not rewrite history. + +**Read this section before writing any plan.** A note here overrides the design above — the design is what we predicted, these notes are what we found. + +Worth recording: + +- **Assumptions this document got wrong.** Any claim in §2–§11 that execution disproved. Say which section, so the next session distrusts the right paragraph. +- **API changes.** The surface in §6 is a sketch, not a contract. If a signature changed, record the real one — the next plan will code against it. +- **Decisions reversed.** §11 lists what we rejected and why. If execution forced a reversal, note which row and what happened. +- **Traps.** Anything that cost more than ~30 minutes, especially cross-layer surprises: tmux behaviour, git worktree edge cases, Cobra lifecycle, test isolation. +- **Deferred work.** Anything consciously skipped, and which plan should pick it up. + +### 14.1 After Plan 1 — the collapse + +Plan 1 is **done** (§12). `internal/project`, `internal/session`, and +`internal/worktree` are gone; `cmd` and `internal/tui` compile against +`internal/herd` + `internal/git`; the shipped profile-blind defect is dead +both structurally and end-to-end (see behaviour changes below). This section +is curated across Tasks 1–5, organised by category rather than by task — +Plan 2's author cares what is true now, not which stage found it. + +#### What this task can measure (the contract, proved) + +Step-1 greps, re-run at close-out (`*.go` only): + +- `git grep -n 'internal/worktree\|internal/session\|internal/project'` → **no hits in Go code.** The three packages have no importers. (CLAUDE.md and the frozen historical docs/plans still name them; that is intended history.) +- `git grep -n 'semconv.SessionName'` → **one production caller: `internal/herd/herd.go`** (2 uses, both inside `Ref.CanonicalName`/session-name derivation). The other two hits are tests asserting *on* the name — `internal/semconv/semconv_test.go` (testing `semconv` itself) and `cmd/session_internal_test.go:42` (recomputing the expected canonical name). This is the whole plan in one command: the nine profile-blind `SessionName("", …)` literals are gone, with nowhere to come back to. +- `git grep -rn 'tmux.NewClient(tmux.NewRealRunner())'` → **one site: `cmd/session.go:24`** (the plan guessed `cmd/tui.go` — wrong). One inline tmux client remains; a candidate for Plan 2's front-end thinning. +- `git grep -rn 'hooks.New(' -- cmd internal/tui` → **one site: `cmd/template.go:72`**, legitimately surviving (see "What Plan 2 inherits"). Not a leak. +- `find internal/herd -name '*.go' -not -name '*_test.go' | xargs wc -l` → **1,036 non-test lines**, not the ~1,550 §5/§8.5 predicted. The estimate was high by ~one third. File split: `session.go` 309, `workspace.go` 375, `herd.go` 158, `project.go` 83, `paths.go` 69, `errors.go` 42. +- `make check` → **green, aggregate coverage 84.5%** (`internal/herd` 87.6%, `internal/git` 90.1%, `cmd` 75.3%, `internal/tui` 84.3%). + +#### Assumptions §2–§11 got wrong + +- **§5 "13 methods" on `WorktreeRunner` — actually 12**, so `git.Runner` is a **13-method** union, not 14. (The plan's own File-Structure table still calls it a "14-method union" — same miscount; the real number is 13.) The §14.1 standing prompt that asked whether "a 14-method union caused pain in test fakes" was asking about the wrong number; the shared `fakeGit` satisfies the 13-method union without pain. +- **§5/§8.5's ~1,550-line `herd` estimate was high** — real is 1,036 non-test lines (above). +- **§6's `New(cfg, profile string, deps)` is wrong** — the real signature is `New(cfg, registry, deps)` (see the deviations table). §8.1's own `herd.New(cfg, registry.Active, …)` sample nil-panics when profiles are off. `cmd.activeProfile()` was deleted as a consequence. +- **§6's "hooks does not appear at all" is half-right** — hooks stay out of the *exported* API (which is what §3.2 actually requires), but an unexported `newHook func(config.HooksConfig) hooks.Hook` field on `Herd`, defaulted in `New` and test-overridable, resolves them per operation. +- **§13 called `project` "the weakest link… the first cut to reverse." Execution disagreed, and the disagreement is preserved on purpose.** Task 3 found folding `project` in *earned its place*: because `Clone` resolves its own hook from `cfg` via `hookFor` (rather than taking a `hooks.Hook` at construction), `cmd/project.go`, `cmd/tui.go`, and all three TUI clone sites (`actions.go`, `form.go`, `agent_picker.go`) collapsed to a single `h.Clone(name)` — "nothing here suggests project should be the first cut to reverse." §13's stance (weakest link, no entanglement, first to extract if `herd` grows unwieldy) still stands as written; a later session should weigh both. `herd` came in *smaller* than §13 feared (1,036 vs ~1,550), which weakens §13's "grows unwieldy" trigger but does not settle the design question. + +#### API changes vs the §6 sketch + +The plan decided these up front and required Task 6 to copy the table into this section verbatim: + +| Spec §6 says | Plan 1 does | Why | +|---|---|---| +| `New(cfg *config.Config, profile string, deps Deps)` | `New(cfg *config.Config, registry *config.ProfileRegistry, deps Deps)` | `WithProfile(name)` needs `ProfilesDir` to call `config.LoadProfile`, and only the registry has it. The spec's own §8.1 sample (`herd.New(cfg, registry.Active, …)`) nil-panics when profiles are off — `config.Load` returns a nil registry, which is exactly why `cmd/services.go:37` guarded it. Passing the registry makes `New` total and deletes `activeProfile()`. | +| `hooks` does not appear anywhere | Unexported field `newHook func(config.HooksConfig) hooks.Hook`, defaulted in `New` | §3.2's constraint is that hooks must not be **bound at construction** — that is what created the dead `Model.sesSvc` fields. A defaulted, test-overridable field satisfies that and keeps the 8 existing hook tests moving intact (§10) instead of being rewritten against real shell commands. It stays out of the exported API. | +| `Handle` has a `Ref` | Same, plus `@codeherd_project` is stamped as a new tmux option | Without it, `Ref.Project` cannot be recovered from a tmux record (the canonical name is ambiguous — §11), so `Sessions()` would return `Handle`s with a half-populated `Ref`. A `Ref` missing only `Project` is a footgun that compiles into `Teardown`. One extra `SetOption` + one format field removes it. | +| — | `Project(name string) (Project, error)` added | `ch show project ` needs one project with `Cloned` status. §6 listed only `Projects()`. | +| — | `CloneAll` dropped, not moved | `project.Service.CloneAll` has **zero non-test callers** — `cmd/project.go` runs its own loop over `h.Projects()`. YAGNI. | +| `RemoteBranch` declared in `herd` | Declared in `internal/git`, re-exported from `herd` as a type alias | `git.Runner.ListRemoteBranches` returns it, so declaring it in `herd` would force a conversion loop at the exec boundary. `type RemoteBranch = git.RemoteBranch` gives §6's surface for free. | +| Files: `session.go worktree.go project.go launch.go teardown.go list.go` | `herd.go errors.go paths.go project.go session.go workspace.go` | Launch/Teardown/List are each ~40 lines and belong beside the domain they operate on. Six files either way. | + +Further surface facts recorded during execution: + +- `ListSessions` widened **9→10 fields** with `Project: fields[9]` appended last, backing the new `SessionRecord.Project` + `semconv.TmuxOptionProject`, so a `Handle` from a list carries a complete `Ref` (`TestSessions_rebuildsCompleteRef`). Field appended last so pre-upgrade 7/8/9-field lines still parse. +- `session.Service` folded in as `Launch`/`Resolve`/`Sessions`/`StopSessions`/`SetStatus`; the `ShowByName`/`StopByName` name-addressed escape hatch is **gone** — the six copies of the list-and-match loop collapse to one `handles()`. +- `EnsureWorkspace` collapsed `New`/`NewFrom`/`NewTracking` into one method + an `addWorktree` switch (`Track` → `git.AddTracking`; `StartPoint` → `freshenStartPoint` + `AddNewBranchFrom`; default → `Add` w/ fallback). `EnsureOpts.Track`/`.StartPoint` are mutually exclusive. With `Track`, **`Workspace.Ref` is authoritative** (the local branch is derived from the remote ref — assert on `ws.Ref.Branch`, not the input Ref). +- `RemoteBranches(project, fetch bool)` replaced `ListRemoteBranches` (no fetch) + `RemoteBranches` (fetches first) — one method, one named argument. +- `git.ParseRef` is exported (Task 5's `freshenStartPoint` lives in `herd` and needs it). +- The TUI `Item` now carries `Ref herd.Ref` (identity), populated from `ws.Ref` — the sanctioned way the front end keeps identity/display split. + +#### Decisions reversed / confirmed (§11) + +- **§11's exported-field `Ref` held up — no reversal.** The only hand-built `herd.Ref{…}` literals are in `_test.go` fixtures and in `handleFrom`/`workspaceFrom` inside `herd`, which mint the Ref from tmux/git data (the sanctioned internal seams). No front end builds a Ref by hand; they carry `ws.Ref`/`sel.Ref` or call `h.Ref`. The opaque-Ref alternative §11 rejected stays rejected. +- The ~13 `Herd` methods "still felt right after writing them all" (Task 5) — `EnsureWorkspace`/`Provision`/`List`/`Teardown`/`RemoteBranches` sit naturally beside `Launch`/`StopSessions`; `Teardown` calling `StopSessions` with the profile in hand is the whole point (one kill loop, keyed on identity). + +#### Traps (cost real time; a later session should expect them) + +- **Task 1 — package-scope `git` identifier collision.** `internal/worktree/integration_test.go` declared a helper `func git(t, …)` that collided with the new `internal/git` import used elsewhere in the package (Go rejects that). Renamed to `runGit`. `goimports` also reflowed `CloneRunner` to a multi-line block — kept, since the lint gate enforces gofmt. +- **Task 2 — `golangci-lint`'s `unused` analyses test + production files together.** The shared `fakes_test.go` tripped 23 `unused` issues while no test consumed the fakes yet; it was **deferred to Task 3** (created there once real callers existed). `go vet`/`go build` do *not* flag this — only the linter does. Same gate flagged `paths.go`'s `worktreesRoot` (no caller yet); covered with a small added test rather than a nolint. Also: `TestHookFor_defaultsToConfiguredHooks` in the brief was unfalsifiable (`hooks.New` never returns nil) — rewritten to override `newHook` with a capturing func and assert the exact threaded `HooksConfig`. +- **Task 4 — `Launch` derives `Path`/`CloneDir` from the `Ref`.** Callers no longer pass a path, so every test that fed an arbitrary path had to `os.MkdirAll` the *derived* worktree path (`/__worktrees/`) or `Launch`'s `os.Stat` returns `ErrPathNotFound`. Helpers: `mkMyappWorktree`, `tuiHerd`. +- **Task 4 — hooks-shadowing footgun.** `cmd/session.go` and `cmd/worktree.go` both had a local `h := hooks.New(projCfg.Hooks)` shadowing the package-global `h *herd.Herd`; once those funcs call `h.Launch` the shadow breaks the build. Renamed the locals to `projHook`. Related: CLI shim callers read both `h` and `cfg`, so test helpers must build both from the same config (`setHerdTmux`). +- **Task 4 — the tmux 9→10 widening must land before any test sets `sessionRow.Project`/`fakeTmux.Sessions` with a project.** +- **Task 5 — completion runs without `PersistentPreRunE`, so the package-global `h` is nil there.** Added `ensureCompletionHerd(cmd)` at the top of `completeBranches`/`completeRemoteBranches`; without it, dropping the `cfg` param off the completion seams nil-panics real shell completion. (The internal `cmd` tests bypass `PersistentPreRunE` too — a `TestMain` seeds a default nil-registry `h`.) +- **Task 5 — identity comes from the directory name, not live HEAD.** `WorktreeIdentityBranch` returns `filepath.Base(path)` for non-main worktrees and the configured default branch for the clone dir, which is exactly what survives a diverged HEAD. +- Tasks 1–2 surfaced little else worth flagging beyond the above; Tasks 3–5 carried the substance. + +#### Behaviour changes (also the changelog entry — 10 total) + +1. **Deleting a worktree or session under an active profile now kills the agent process too.** The shipped defect: a profile-prefixed session (and its running agent) used to survive the delete. Verified end-to-end — under `CODEHERD_TMUX_SOCKET` with profile `work` active, `ch create session myapp feat` then `ch delete worktree myapp feat --force` leaves the agent PID dead and the worktree gone. +2. **`ch list worktree` now shows `(running)` for sessions under an active profile.** The marker was previously hardcoded to the no-profile session name, so it never appeared once a profile prefixed the real name. +3. **`ch delete session --force` no longer errors when nothing is running.** Stopping nothing is treated as success; the "not found" message now comes only from the non-`--force` probe. +4. **`ch create worktree --attach` now starts a profile-prefixed session.** It never did — the old path passed no profile, creating an unprefixed session nothing else could address. +5. **`ch create session ` now reports "not cloned" first** (agent resolution moved after the worktree-existence check), exits via the same `worktreeErr` path as `create worktree` instead of returning, and prints its "creating…" banner only on the successful path. Exit code unchanged (1). +6. **The TUI create-worktree form now provisions (copies files, renders `.herd` templates) even when you do not attach.** Previously `attach=false` created a bare worktree with nothing rendered — a latent defect fixed during the collapse. Create-then-attach now provisions in exactly one place (no double render). +7. **Sessions created before this release (missing the `@codeherd_project` stamp) are recognized, listed, killed, and healed automatically.** The domain matches live sessions on the frozen `@codeherd_canonical_name` (the identity of record every prior version used), not on a name rebuilt from `Ref` parts, so a missing project can no longer hide a session from the TUI or a teardown. On first observation the missing project is recovered from the canonical name and re-stamped, healing the session to first-class. No orphans, no manual migration. +8. **Re-tracking an already-tracked worktree now fetches first and returns a raw "local branch exists" error** instead of the friendly `ErrWorktreeExists`. Edge case (`worktreeErr` does not translate this sentinel). +9. **`delete worktree` / delete-all now stat-gate on the worktree directory before killing sessions.** A session whose worktree was removed out-of-band now survives a delete-all (recoverable via agent-only `delete session`). +10. **The TUI now renders a diverged worktree's live HEAD branch** (`DisplayBranch`) plus an "on ``" hint, where it used to show the identity branch. Intentional display/identity split; `ch list worktree` CLI output is unchanged. + +#### What Plan 2 inherits (read this first) + +- **`cmd/errors.go` still has its two translators (`sessionErr`/`worktreeErr`) and its `os.Exit(1)` inside `RunE`.** Plan 2's §9 target: collapse to one translator and remove `os.Exit` from `RunE`. Deliberately untouched here — changing exit codes mid-collapse, while the domain underneath still moved, was out of scope. +- **`cmd/template.go` keeps its own `hooks.New` (line 72) and its direct `herdtemplate` call** — the one front end that legitimately does not route through `herd`, because `ch template` takes an arbitrary `[dir]` and supports `--dry-run`, neither of which fits `Provision`'s premise that paths derive from the `Ref`. Only its profile-blind `semconv.SessionName("", …)` was fixed in place. Leave it, or give `herd` a dir-taking provision variant if Plan 2 wants full routing. +- **One inline `tmux.NewClient(tmux.NewRealRunner())` remains at `cmd/session.go:24`** — the last front-end site building a tmux client by hand. Candidate for Plan 2's thinning. +- **`Model.cfg`:** the TUI `profileCache` and the shared-registry mutation were **deleted, not moved** — `switchProfile` now calls `m.herd.WithProfile(next)` (one small TOML read per keypress; `m.herd` is an immutable value so the old refresh/profile-snapshot race is structurally gone). Whether `Model.cfg` itself can now go away entirely is unverified and left for Plan 2 to measure; if the per-keypress read ever bites, the cache belongs on `Herd`, not the TUI. +- **Front ends are not yet thin** (§8.5) — `cmd` is 75.3% covered and still constructs a tmux client and a `hooks.New`. Plan 2 should re-measure line counts rather than trust §8.5's estimates (which already proved high for `herd`). +- **The profile × operation integration matrix is still unfilled** (§10's three gap cells: `StopSessions`, `Teardown`, `Resolve` under an active profile). They have *unit* coverage here (Tasks 4–5); *integration* coverage against real tmux is Plan 3, and is the gate that would have caught the original defect. + + +### 14.2 After Plan 2 — front-end thinning + +**Scope correction.** §12's "~600 fewer lines of thinning" was already delivered by Plan 1 +(`cmd/services.go` + `*ForProfile` shims deleted, `Model.sesSvc`/`projSvc` gone, `profileCache` +gone, front ends already at §8.5 targets). The only §9 work left was the **error vocabulary**, +which is all Plan 2 did — two tasks, commits `d5e97ad` (CLI) and `02f2c65` (TUI). Base `4d0f413`. + +**What landed:** +- `cmd/errors.go`'s `worktreeErr`+`sessionErr` collapsed into one `herdErr(project, branch, err) error` + that **returns** the friendly error. `Execute` (`cmd/root.go`) is now the single print site, + prefixing every user-facing error `Error: `. All 9 call sites migrated; the worktree `--attach` + site passes `ws.Ref.Project/Branch` (authoritative identity), not the positional branch. +- `internal/tui/errors.go`'s `humanize(err) string` maps the same sentinels to concise status + lines; the TUI's two render sites (`statusMsg`, `remotePicker.errText`) route through it. +- Coverage 85.3% throughout; final whole-branch review (`4d0f413..02f2c65`): **Ready to merge, Yes.** + +**Answering the standing prompts:** +- **Exit codes unchanged** — removing `os.Exit(1)` from `RunE` moved the exit to `main.go` (already + exits 1 on non-nil `Execute` return). The *observable* change is a gain: config-load and other + `RunE` errors now also get the `Error: ` prefix, and each error prints exactly once (the old + translators printed *and* `os.Exit`ed, bypassing `Execute`). No double-print (the `--all` clone + path prints its own per-project line then returns a distinct summary error). +- **Line counts** — front ends were already at/near §8.5 before this plan; Plan 2 removed the + translator duplication but was not a bulk-thinning plan. No re-measurement needed. +- **`WithProfile` vs `profileCache`** — already answered by Plan 1; `switchProfile` uses + `m.herd.WithProfile(next)`, cache gone. Not touched here. + +**Measured non-goals (deliberately kept, see plan §"Measured non-goals"):** +- `Model.cfg` stays — removing it forces ~30 TUI test sites to build a real `Herd` (the ceremony + §11 rejected). It is a redundant cache resynced at the only two herd-swap sites. +- The inline `tmux.NewClient` attach sites (`execTmuxAttach`, `switchClientCmd`) stay — they are + interactive-attach exec mechanism (cf. `syscall.Exec`), not the service-injection smell of §3.2. + +**Accepted Minors / follow-up candidates (none block merge):** +1. Stale comments still name the deleted `worktreeErr`/`sessionErr` and old `os.Exit` behavior in + `cmd/worktree_test.go:110`, `cmd/session_internal_test.go:253`, `cmd/session_test.go:88,109-112`. + Comment-only; a fast-follow sweep would stop pointing future readers at gone symbols. +2. Two defensive fallback branches are effectively **dead**: `herdErr`'s `ErrSessionExists`→`return err` + and `humanize`'s `"Session already exists."`. `session.go` only ever returns `*SessionExistsError` + and the bare sentinel is never wrapped-and-returned, so `errors.As` always succeeds. Harmless. +3. **The "one vocabulary" goal is not yet total across `cmd`.** `list worktree` (`cmd/worktree.go:35`) + wraps as `fmt.Errorf("list: %w", err)`, and the clone paths handle `*AlreadyClonedError` with their + own `Warning:`/`Error:` lines — neither routes through `herdErr`. Correctly out of this plan's 9 + sites; a candidate for Plan 3 or a cleanup if total coverage is the end state. +4. `herd.ErrAlreadyCloned` / `ErrLocalBranchExists` are unmapped in both front ends (fall through to + the raw sentinel text). Assessed acceptable: behavior is consistent CLI↔TUI, and the clone flows + intercept `*AlreadyClonedError` upstream. Polish only. + +### 14.3 After Plan 3 — the coverage contract + +**Done.** The §10 matrix is filled at the `internal/herd` layer by one new file, +`internal/herd/matrix_integration_test.go` (`//go:build integration`, package `herd`), across +three commits — base `4b03da3`, range `4b03da3..aed43fd`. Test-only; **zero production changes.** +Final whole-branch review: **Ready to merge, Yes.** + +**Scope correction.** §12 framed Plan 3 as three gap cells. Verified: the profile-scoped operations +already had *unit* (fake-tmux) coverage from Plan 1 (`TestStopSessions_underProfile_*`, +`TestTeardown_underProfile_*`, `TestList_underProfile_*`) — the "unit coverage" §14.1 named. The real +gap was *integration* (real tmux): **no test anywhere built `herd.New(…, Deps{Tmux: tmux.NewRealRunner()})`.** +Plan 3 is the first, and is the layer §10's matrix names (herd operations), one level below §3.3's +CLI-level `TestProfiles_sessionIsolationAcrossProfiles` (which covers create+list through Cobra). + +**What landed** — a two-column table `matrixProfiles` = {profile off (nil registry), profile on +(`&config.ProfileRegistry{Active:"work"}`)} driven through an isolated real tmux + real git harness: +- `TestMatrix_LaunchAndResolve` — Launch + the **Resolve** gap cell. +- `TestMatrix_StopSessions` — the **StopSessions** gap cell (agent + shell, `StopOpts{All}`). +- `TestMatrix_Teardown` + `TestMatrix_TeardownRefusesRunning` — the **Teardown** gap cell, the shipped + defect's row: force teardown kills the profile-prefixed session AND removes the worktree; non-force + refuses with `ErrSessionRunning` and touches nothing. + +**Answering the standing prompts:** +- **Did it find bugs, or confirm?** Confirmed. All 12 subtests pass on tmux 3.4; the shipped + orphaned-agent defect (§2/§8.3) does **not** reproduce under a profile — Plan 1's fix is now proven + end-to-end against real tmux, not just fakes. The review verified the assertions would genuinely + *fail* if the profile-blind name rebuild were reintroduced, so it is a live gate, not a no-op. +- **Cheap enough to keep green?** Yes. Each subtest spins a private tmux server (socket under + `t.TempDir()`) + a real git clone + a `sleep 300` agent, all reaped by a `kill-server` cleanup; no + `t.Parallel`, no cross-test leakage or ordering dependence. It runs only in `make check`'s + `test-integration` phase and `t.Skip`s where tmux can't daemonize (sandboxed CI). The `//go:build + integration` tag keeps it **out of the coverage phase**, so the 80% floor is untouched (verified: + `go test ./internal/herd/` without the tag finds none of these tests). +- **Promote to a CLAUDE.md standing rule?** The final review recommends it — the file cleanly encodes + the "every operation × profile off/on, keyed on identity" contract. Deferred as a post-merge + decision; a one-line rule ("new `herd` operations that touch tmux get a `matrixProfiles` row") would + keep the matrix from rotting. Not done here (out of Plan 3's test-only scope). + +**Accepted Minors (none block merge):** +1. Shell name is hand-built as `ref.CanonicalName() + "~sh"` rather than `semconv.ShellSessionName(...)` + (`matrix_integration_test.go`); a following `t.Fatalf` precondition makes any drift fail loudly, so + it's a maintainability nit. Optional: call `semconv.ShellSessionName` directly. +2. `tmuxHasSession` swallows the `exec` error, so an unreachable server reads the same as "no session"; + near-zero risk (harness pre-probes, server alive during assertions). Acceptable. +3. `setupMatrixHerd`'s worktree-path return is unused by Task 1's own test (used by the Teardown + tests) — intended. + +**The refactor is complete.** All three plans (collapse, front-end thinning, coverage contract) plus +the session-canonical-compat plan are done and merge-ready on this branch. + diff --git a/docs/superpowers/specs/2026-07-16-session-canonical-compat-design.md b/docs/superpowers/specs/2026-07-16-session-canonical-compat-design.md new file mode 100644 index 0000000..9a20933 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-session-canonical-compat-design.md @@ -0,0 +1,126 @@ +# Backward compatibility for pre-`@codeherd_project` sessions + +**Status:** design +**Date:** 2026-07-16 +**Related:** `docs/superpowers/specs/2026-07-15-herd-domain-package-design.md` §14.1 (behaviour change #7 and the "What Plan 2 inherits" hardening note this design resolves). + +## 1. Problem + +The herd collapse (Plan 1) added a new tmux session option, `@codeherd_project`, stamped at launch and used to rebuild a session's `Ref`. Sessions created by any earlier version carry every other option — including the frozen `@codeherd_canonical_name` — but not this one. + +The new binary lists and kills sessions by rebuilding a canonical name from `Ref` parts (`hd.Ref.CanonicalName()`), which is derived from `@codeherd_profile` + `@codeherd_project` + `@codeherd_branch`. For a pre-upgrade session the project part is empty, so the rebuilt name never equals the session's real, stored name. Consequences: + +- The session is **dropped from the TUI/`list`** (its rebuilt key matches no workspace in the `List` join). +- The session **survives `delete worktree`** — `StopSessions` compares the front end's real-project `Ref` against the session's empty-project rebuilt name, they never match, so the worktree is force-removed while the agent process keeps running. This is the original orphan defect, resurfacing only for sessions live across the upgrade boundary. + +The `SessionRecord.Project` doc comment claims such a session "fails loudly (`project "" is not configured`)" in `Teardown`. It does not: no path feeds a Handle's own empty-project `Ref` to a path resolver, so that guard never fires. The behaviour is a silent orphan, not a loud failure. + +## 2. Root cause + +Every version back to `29f11be` stamped `@codeherd_canonical_name` with the fully-qualified name `SessionName(profile, project, branch)` and **matched sessions on that stored value**. The collapse stopped trusting the stored name and started rebuilding it from parts. Rebuilding is fragile by construction: any option that feeds the rebuild (today the project; tomorrow anything else) becomes load-bearing for matching, so adding one silently breaks every session that predates it. + +## 3. Principle + +**The stored `@codeherd_canonical_name` is the session's identity of record.** It is frozen at creation and is the only correct key for matching a live session. Matching keys on it; it is never rebuilt from `Ref` parts. `Ref` remains the input front ends supply and the value used to *derive* paths and names — but not the key used to *match* an already-running session. + +This restores parity with every prior version and is inherently forward-safe: a future added option cannot break matching, because matching never depends on the parts. + +## 4. Design + +Three layers, separated by the strength of guarantee each provides. Layer 1 is the correctness guarantee; layers 2–3 are best-effort recovery that improves display and heals the state permanently. + +### Layer 1 — match on the stored canonical (the guarantee) + +`Handle` gains a field: + +```go +type Handle struct { + ID string + Canonical string // @codeherd_canonical_name — the frozen identity, the match key + Ref Ref + // …existing fields… +} +``` + +`Canonical` is set from `r.CanonicalName`. The three match sites switch from the rebuilt name to the stored one: + +| Site | Was | Becomes | +|---|---|---| +| `Resolve` (session.go) | `hd.Ref.CanonicalName() == canonical` | `hd.Canonical == canonical` | +| `StopSessions` (session.go) | `hd.Ref.CanonicalName() != canonical` | `hd.Canonical != canonical` | +| `List` join (workspace.go) | `key := hd.Ref.CanonicalName()` | `key := hd.Canonical` | + +On the front-end side, the comparison value stays `ref.CanonicalName()` — front ends always hold a complete, real-project `Ref`, so their rebuilt name is correct. For a **new** session, `hd.Canonical == hd.Ref.CanonicalName()`, so this change is a no-op. For a **pre-upgrade** session, the stored canonical is the real name and matches. `Sessions()`'s profile filter (`hd.Ref.Profile == h.profile`) is unchanged — `@codeherd_profile` is stored on pre-upgrade sessions, so the filter already works. + +**After layer 1 alone, listing and killing are correct regardless of whether the project is ever recovered.** + +### Layer 2 — recover the project (best-effort, for display) + +A pure, independently testable helper reconstructs the missing project by iterate-and-validate against configured projects: + +```go +// resolveProject finds the configured project whose canonical name matches the +// stored one, given the (stored) profile and branch. It disambiguates by +// validating against real config rather than string-splitting the name, so a +// project that no longer exists in config yields "", false. +func resolveProject(cfg *config.Config, profile, branch, canonical string) (string, bool) { + for name := range cfg.Projects { + if semconv.SessionName(profile, name, branch) == canonical { + return name, true + } + } + return "", false +} +``` + +Profile and branch are both stored on the record, so the only unknown is the project, and the match is unambiguous. Iterating and validating (rather than stripping the prefix/suffix) guarantees we never recover a name that isn't a real project — which matters because layer 3 writes it back. + +### Layer 3 — self-heal (write once, so the cost is paid once) + +When layer 2 recovers a project, the live session is re-stamped so it becomes first-class permanently and the recovery runs only once. This write lives in the same single place as layers 1–2 (see Reusability) and carries a comment stating it is a backward-compatibility shim: + +```go +// Backward compatibility: sessions created before @codeherd_project existed +// carry no project stamp. Recover it from the frozen canonical name and stamp +// it, so the session heals to first-class on first observation. Idempotent — +// once stamped, future reads take the value directly and skip this path. +if r.Project == "" && r.CanonicalName != "" { + if project, ok := resolveProject(h.cfg, r.Profile, r.Branch, r.CanonicalName); ok { + hd.Ref.Project = project + _ = h.tmux.SetOption(r.Name, semconv.TmuxOptionProject, project) + } +} +``` + +The heal happens **anywhere a session is observed**, including read-only paths like `ch list session`. This is a deliberate choice: it heals as early and as broadly as possible, the write is idempotent and benign, and it keeps the logic in one place rather than special-casing which callers may mutate. If layer 2 fails to recover a project (project removed from config), no write happens and the session still lists and kills via layer 1, with a blank project column. + +### Reusability — one place, no duplication + +All recovery and healing lives in the single `handles()` chokepoint, which is already documented as "the single place a tmux record becomes a Handle." Every consumer funnels through it: + +- `Resolve`, `Sessions`, `StopSessions` → `h.handles()` directly +- `List`'s join → `h.Sessions()` → `h.handles()` +- `Teardown`'s pre-check → `h.handles()` + +`handleFrom` becomes a method (`h.handleFrom(r) Handle`) so it can read `h.cfg` and call `h.tmux.SetOption`; `handles()` calls it per record. The recovery arithmetic is the pure `resolveProject` helper (layer 2), unit-testable in isolation; the heal is the one `SetOption` call inside `h.handleFrom`. There is exactly one implementation of each, reached by every path. + +## 5. Testing + +- **`resolveProject` unit tests** — recovers the right project under a profile, under no profile, returns `false` when no configured project matches, and is unambiguous when two projects share a branch name but differ in project name. +- **`handles()` recovery + heal test** — a `fakeTmux` row with empty `Project` but a real stored `CanonicalName` yields a `Handle` with `Ref.Project` recovered, `Canonical` set, and a `SetOption(@codeherd_project, …)` call recorded. A second `handles()` call over an already-stamped row issues no further `SetOption` (idempotence). +- **Compat regression test (the reason this exists)** — the mirror of Plan 1's `TestConfirmDeleteAll_divergedHeadSessionIsKilled`: a pre-upgrade session (row with real `CanonicalName`, empty `Project`) under an active profile is killed by `StopSessions`/`Teardown`, matched by ID via the stored canonical. Names the compat guarantee explicitly. +- **Forward-safety assertion** — a test documenting that a `Handle` created from a record with a project matches identically to one without, once healed, so a future added option cannot regress matching. + +Tmux-touching tests isolate per the repo convention (`CODEHERD_TMUX_SOCKET` under `t.TempDir()`, clear `$TMUX`, probe-and-skip, `kill-server` cleanup). + +## 6. Documentation changes + +- **`SessionRecord.Project` comment** (`internal/tmux/client.go`) — remove the inaccurate "fails loudly (`project "" is not configured`)" claim; state that pre-upgrade records carry no project and that `internal/herd` recovers and heals it from the canonical name on observation. +- **Spec §14.1 behaviour change #7** — rewrite: pre-upgrade sessions are now recognized, listed, killed, and healed on first observation (drop the "survives teardown" caveat). This ships as the changelog line for the compat release. +- **Spec §14.1 "What Plan 2 inherits"** — remove the stored-canonical hardening note; it is done here. + +## 7. Scope and non-goals + +- **In scope:** `internal/herd` (session.go `Handle`/`handleFrom`/`handles`/`Resolve`/`StopSessions`, workspace.go `List` join), a `resolveProject` helper, tests, and the three doc edits. One focused change. +- **Out of scope / non-goals:** No new CLI command (healing is automatic, not a `migrate` verb). No change to how new sessions are stamped. Independent of Plan 2's front-end thinning and Plan 3's integration matrix — this can land before or after either. +- **Boundary condition:** a session whose project was removed from config after the session started lists with a blank project and is not healed, but is still killed correctly. Acceptable — there is no project to heal to. diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..e24b774 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,289 @@ +// Package git wraps git command execution. It is a mechanism package: it +// never sees the config, the active profile, or a Ref — it only takes paths +// and refs it is handed. Exactly one real implementation exists; the +// interfaces exist so internal/herd can fake the exec boundary in tests. +package git + +import ( + "bufio" + "errors" + "fmt" + "os/exec" + "strings" +) + +// WorktreeInfo holds data from a single git worktree entry. +type WorktreeInfo struct { + Path string + Branch string // empty if detached HEAD + Detached bool // true when HEAD is detached (e.g. rebase in progress) +} + +// RemoteBranch is one remote-tracking branch (e.g. origin/feature-x). +type RemoteBranch struct { + Remote string + Branch string + Ref string // "/" +} + +// WorktreeRunner abstracts git worktree operations for testability. +type WorktreeRunner interface { + Add(cloneDir, worktreePath, branch string) error + AddNewBranch(cloneDir, worktreePath, branch string) error + AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error + Remove(cloneDir, worktreePath string) error + List(cloneDir string) ([]WorktreeInfo, error) + + Fetch(cloneDir, remote, branch string) error + FetchAll(cloneDir string) error + FastForward(cloneDir, remote, branch string) error + Remotes(cloneDir string) ([]string, error) + ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) + AddTracking(cloneDir, worktreePath, branch, remoteRef string) error + HasLocalBranch(cloneDir, branch string) (bool, error) +} + +// CloneRunner abstracts git clone execution to enable testing. +type CloneRunner interface { + Clone(repo, path, branch string) error +} + +// Runner is the union both herd and its tests depend on. Splitting it +// further is out of scope: it sits at the exec boundary where one real +// implementation exists. +type Runner interface { + WorktreeRunner + CloneRunner +} + +// RealRunner runs git commands via os/exec. +type RealRunner struct{} + +// NewRealRunner returns a Runner backed by the system git binary. +func NewRealRunner() *RealRunner { return &RealRunner{} } + +func (r *RealRunner) Add(cloneDir, worktreePath, branch string) error { + cmd := exec.Command("git", "worktree", "add", worktreePath, branch) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree add: %w\n%s", err, out) + } + return nil +} + +func (r *RealRunner) AddNewBranch(cloneDir, worktreePath, branch string) error { + cmd := exec.Command("git", "worktree", "add", "-b", branch, worktreePath) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree add -b: %w\n%s", err, out) + } + return nil +} + +func (r *RealRunner) AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error { + cmd := exec.Command("git", "worktree", "add", "-b", branch, worktreePath, startPoint) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree add -b (from): %w\n%s", err, out) + } + return nil +} + +func (r *RealRunner) Remove(cloneDir, worktreePath string) error { + cmd := exec.Command("git", "worktree", "remove", worktreePath) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree remove: %w\n%s", err, out) + } + return nil +} + +func (r *RealRunner) List(cloneDir string) ([]WorktreeInfo, error) { + cmd := exec.Command("git", "worktree", "list", "--porcelain") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git worktree list: %w", err) + } + return parseWorktreePorcelain(string(out)), nil +} + +func (r *RealRunner) Fetch(cloneDir, remote, branch string) error { + cmd := exec.Command("git", "fetch", remote, branch) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch %s %s: %w\n%s", remote, branch, err, out) + } + return nil +} + +func (r *RealRunner) FetchAll(cloneDir string) error { + cmd := exec.Command("git", "fetch", "--all", "--prune") + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch --all: %w\n%s", err, out) + } + return nil +} + +// FastForward advances the local branch to / without losing +// local commits. It first tries a non-checkout ref update (works when the +// branch is not checked out); if that fails it falls back to a fast-forward-only +// merge (for the clone's currently checked-out branch). Either failure is +// reported but treated as best-effort by callers. +func (r *RealRunner) FastForward(cloneDir, remote, branch string) error { + refspec := branch + ":" + branch + cmd := exec.Command("git", "fetch", remote, refspec) + cmd.Dir = cloneDir + if _, err := cmd.CombinedOutput(); err == nil { + return nil + } + remoteRef := remote + "/" + branch + cmd = exec.Command("git", "merge", "--ff-only", remoteRef) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("fast-forward %s: %w\n%s", branch, err, out) + } + return nil +} + +func (r *RealRunner) Remotes(cloneDir string) ([]string, error) { + cmd := exec.Command("git", "remote") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git remote: %w", err) + } + var names []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if s := strings.TrimSpace(line); s != "" { + names = append(names, s) + } + } + return names, nil +} + +func (r *RealRunner) ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) { + cmd := exec.Command("git", "for-each-ref", "--format=%(refname:short)", "refs/remotes") + cmd.Dir = cloneDir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git for-each-ref: %w", err) + } + return parseRemoteBranches(string(out)), nil +} + +func (r *RealRunner) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + cmd := exec.Command("git", "worktree", "add", "--track", "-b", branch, worktreePath, remoteRef) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git worktree add --track: %w\n%s", err, out) + } + return nil +} + +func (r *RealRunner) HasLocalBranch(cloneDir, branch string) (bool, error) { + ref := "refs/heads/" + branch + cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref) + cmd.Dir = cloneDir + err := cmd.Run() + if err == nil { + return true, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return false, nil + } + return false, fmt.Errorf("git show-ref %s: %w", ref, err) +} + +// Clone runs git clone. If branch is non-empty, passes --branch . +func (r *RealRunner) Clone(repo, path, branch string) error { + args := []string{"clone"} + if branch != "" { + args = append(args, "--branch", branch) + } + args = append(args, repo, path) + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git clone: %w\n%s", err, out) + } + return nil +} + +// parseWorktreePorcelain parses the output of `git worktree list --porcelain`. +// Blocks are separated by blank lines. +func parseWorktreePorcelain(output string) []WorktreeInfo { + var result []WorktreeInfo + var current WorktreeInfo + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "worktree "): + current = WorktreeInfo{Path: strings.TrimPrefix(line, "worktree ")} + case strings.HasPrefix(line, "branch "): + ref := strings.TrimPrefix(line, "branch ") + current.Branch = strings.TrimPrefix(ref, "refs/heads/") + case line == "detached": + current.Detached = true + case line == "": + if current.Path != "" { + result = append(result, current) + current = WorktreeInfo{} + } + } + } + if current.Path != "" { + result = append(result, current) + } + return result +} + +// parseRemoteBranches parses `git for-each-ref --format=%(refname:short) refs/remotes`. +// The remote name is the segment before the first slash; the rest is the branch +// (which may contain slashes). Symbolic */HEAD entries are skipped. +func parseRemoteBranches(output string) []RemoteBranch { + var result []RemoteBranch + for _, line := range strings.Split(output, "\n") { + s := strings.TrimSpace(line) + if s == "" { + continue + } + idx := strings.Index(s, "/") + if idx <= 0 { + continue + } + remote, branch := s[:idx], s[idx+1:] + if branch == "" || branch == "HEAD" { + continue + } + result = append(result, RemoteBranch{Remote: remote, Branch: branch, Ref: s}) + } + return result +} + +// ParseRef splits a user-supplied ref into a remote and branch. When ref is +// "/" and matches a configured remote, it returns +// (remote, rest, true). Otherwise it defaults to ("origin", ref, false), which +// keeps branch names containing slashes (e.g. feature/login) intact. +func ParseRef(remotes []string, ref string) (remote, branch string, explicit bool) { + if idx := strings.Index(ref, "/"); idx > 0 { + candidate := ref[:idx] + for _, r := range remotes { + if r == candidate { + return candidate, ref[idx+1:], true + } + } + } + return "origin", ref, false +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..982d8b1 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,107 @@ +package git + +import "testing" + +func TestParseWorktreePorcelain(t *testing.T) { + input := `worktree /home/user/projects/myapp +HEAD abc123 +branch refs/heads/main + +worktree /home/user/projects/myapp__worktrees/feature +HEAD def456 +branch refs/heads/feature + +worktree /home/user/projects/myapp__worktrees/detached +HEAD ghi789 +detached + +` + got := parseWorktreePorcelain(input) + + if len(got) != 3 { + t.Fatalf("expected 3 entries, got %d", len(got)) + } + if got[0].Path != "/home/user/projects/myapp" || got[0].Branch != "main" { + t.Errorf("entry 0: %+v", got[0]) + } + if got[1].Path != "/home/user/projects/myapp__worktrees/feature" || got[1].Branch != "feature" { + t.Errorf("entry 1: %+v", got[1]) + } + if got[2].Branch != "" { + t.Errorf("entry 2 should have empty branch for detached HEAD, got %q", got[2].Branch) + } +} + +func TestParseWorktreePorcelain_empty(t *testing.T) { + got := parseWorktreePorcelain("") + if len(got) != 0 { + t.Errorf("expected empty, got %v", got) + } +} + +// TestParseWorktreePorcelain_noTrailingNewline exercises the tail-append path +// where the last entry is not followed by a blank line. +func TestParseWorktreePorcelain_noTrailingNewline(t *testing.T) { + input := "worktree /home/user/projects/myapp\nHEAD abc123\nbranch refs/heads/main" + got := parseWorktreePorcelain(input) + if len(got) != 1 { + t.Fatalf("expected 1 entry, got %d", len(got)) + } + if got[0].Path != "/home/user/projects/myapp" || got[0].Branch != "main" { + t.Errorf("unexpected entry: %+v", got[0]) + } +} + +func TestParseWorktreePorcelain_detachedFlag(t *testing.T) { + input := "worktree /p/myapp__worktrees/detached\nHEAD ghi789\ndetached\n\n" + got := parseWorktreePorcelain(input) + if len(got) != 1 { + t.Fatalf("expected 1 entry, got %d", len(got)) + } + if !got[0].Detached { + t.Errorf("expected Detached=true for detached HEAD entry") + } + if got[0].Branch != "" { + t.Errorf("expected empty Branch for detached HEAD, got %q", got[0].Branch) + } +} + +func TestParseRemoteBranches(t *testing.T) { + input := "origin/main\norigin/HEAD\norigin/feature/login\nupstream/bugfix\n" + got := parseRemoteBranches(input) + want := []RemoteBranch{ + {Remote: "origin", Branch: "main", Ref: "origin/main"}, + {Remote: "origin", Branch: "feature/login", Ref: "origin/feature/login"}, + {Remote: "upstream", Branch: "bugfix", Ref: "upstream/bugfix"}, + } + if len(got) != len(want) { + t.Fatalf("got %d entries, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("entry %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestParseRef(t *testing.T) { + remotes := []string{"origin", "upstream"} + cases := []struct { + ref string + remote, branch string + explicit bool + }{ + {"feat-x", "origin", "feat-x", false}, + {"feature/login", "origin", "feature/login", false}, + {"origin/feat-x", "origin", "feat-x", true}, + {"upstream/feature/login", "upstream", "feature/login", true}, + {"notaremote/x", "origin", "notaremote/x", false}, + } + for _, tc := range cases { + gotR, gotB, gotE := ParseRef(remotes, tc.ref) + if gotR != tc.remote || gotB != tc.branch || gotE != tc.explicit { + t.Errorf("ParseRef(%q) = (%q,%q,%v), want (%q,%q,%v)", + tc.ref, gotR, gotB, gotE, tc.remote, tc.branch, tc.explicit) + } + } +} diff --git a/internal/worktree/realrunner_test.go b/internal/git/realrunner_test.go similarity index 87% rename from internal/worktree/realrunner_test.go rename to internal/git/realrunner_test.go index d52d7b0..63bb9c0 100644 --- a/internal/worktree/realrunner_test.go +++ b/internal/git/realrunner_test.go @@ -1,4 +1,4 @@ -package worktree +package git import ( "os" @@ -56,7 +56,7 @@ func realRunnerRepos(t *testing.T) string { func TestRealWorktreeRunner_RemotesAndBranches(t *testing.T) { clone := realRunnerRepos(t) - r := NewRealWorktreeRunner() + r := NewRealRunner() remotes, err := r.Remotes(clone) if err != nil { @@ -81,7 +81,7 @@ func TestRealWorktreeRunner_RemotesAndBranches(t *testing.T) { func TestRealWorktreeRunner_HasLocalBranch(t *testing.T) { clone := realRunnerRepos(t) - r := NewRealWorktreeRunner() + r := NewRealRunner() has, err := r.HasLocalBranch(clone, "main") if err != nil || !has { @@ -95,7 +95,7 @@ func TestRealWorktreeRunner_HasLocalBranch(t *testing.T) { func TestRealWorktreeRunner_FetchAndFastForward(t *testing.T) { clone := realRunnerRepos(t) - r := NewRealWorktreeRunner() + r := NewRealRunner() if err := r.Fetch(clone, "origin", "feat-x"); err != nil { t.Errorf("Fetch: %v", err) @@ -114,7 +114,7 @@ func TestRealWorktreeRunner_FetchAndFastForward(t *testing.T) { func TestRealWorktreeRunner_AddTrackingAndList(t *testing.T) { clone := realRunnerRepos(t) - r := NewRealWorktreeRunner() + r := NewRealRunner() if err := r.Fetch(clone, "origin", "feat-x"); err != nil { t.Fatalf("Fetch: %v", err) @@ -144,7 +144,7 @@ func TestRealWorktreeRunner_AddTrackingAndList(t *testing.T) { func TestRealWorktreeRunner_AddNewBranchFromAndRemove(t *testing.T) { clone := realRunnerRepos(t) - r := NewRealWorktreeRunner() + r := NewRealRunner() wtPath := filepath.Join(filepath.Dir(clone), "wt-new") if err := r.AddNewBranchFrom(clone, wtPath, "new-branch", "main"); err != nil { @@ -166,7 +166,7 @@ func TestRealWorktreeRunner_AddNewBranchFromAndRemove(t *testing.T) { func TestRealWorktreeRunner_AddExistingBranch(t *testing.T) { clone := realRunnerRepos(t) - r := NewRealWorktreeRunner() + r := NewRealRunner() // Create a local branch to check out into a worktree via Add. runGit(t, clone, "branch", "local-x", "main") @@ -175,3 +175,21 @@ func TestRealWorktreeRunner_AddExistingBranch(t *testing.T) { t.Errorf("Add: %v", err) } } + +func TestRealRunner_Clone(t *testing.T) { + src := t.TempDir() + runGit(t, src, "init", "-b", "main", ".") + if err := os.WriteFile(filepath.Join(src, "f.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, src, "add", ".") + runGit(t, src, "commit", "-m", "init") + + dst := filepath.Join(t.TempDir(), "clone") + if err := NewRealRunner().Clone(src, dst, "main"); err != nil { + t.Fatalf("Clone: %v", err) + } + if _, err := os.Stat(filepath.Join(dst, "f.txt")); err != nil { + t.Errorf("cloned tree missing f.txt: %v", err) + } +} diff --git a/internal/herd/errors.go b/internal/herd/errors.go new file mode 100644 index 0000000..9884130 --- /dev/null +++ b/internal/herd/errors.go @@ -0,0 +1,42 @@ +package herd + +import ( + "errors" + "fmt" +) + +// Sentinels. These are the whole error vocabulary of the domain; front ends +// match these and nothing else. They span three packages today, which is why +// cmd/errors.go grew two translators that both handle ErrNotCloned and print +// different text for it. +var ( + ErrNotCloned = errors.New("project not cloned") + ErrAlreadyCloned = errors.New("already cloned") + ErrWorktreeExists = errors.New("worktree already exists") + ErrWorktreeNotFound = errors.New("worktree not found") + ErrLocalBranchExists = errors.New("local branch already exists") + ErrSessionExists = errors.New("session already exists") + ErrSessionNotFound = errors.New("session not found") + ErrSessionRunning = errors.New("session is running") + ErrPathNotFound = errors.New("worktree path not found") +) + +// AlreadyClonedError carries the path that already exists. +type AlreadyClonedError struct{ Path string } + +func (e *AlreadyClonedError) Error() string { return e.Path + " already exists, skipping" } +func (e *AlreadyClonedError) Unwrap() error { return ErrAlreadyCloned } + +// SessionExistsError is returned by Launch when a session for the same Ref +// and type is already running. It carries the Ref so a front end can print an +// attach hint without re-deriving identity. +type SessionExistsError struct { + Ref Ref + Type SessionType +} + +func (e *SessionExistsError) Error() string { + return fmt.Sprintf("%s: %s/%s (%s)", ErrSessionExists.Error(), e.Ref.Project, e.Ref.Branch, e.Type) +} + +func (e *SessionExistsError) Unwrap() error { return ErrSessionExists } diff --git a/internal/herd/fakes_test.go b/internal/herd/fakes_test.go new file mode 100644 index 0000000..db3b3b3 --- /dev/null +++ b/internal/herd/fakes_test.go @@ -0,0 +1,299 @@ +package herd + +import ( + "fmt" + "strings" + "sync" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/hooks" +) + +// fakeGit satisfies the whole git.Runner union. Every method is a func field +// defaulting to success, so a test overrides only what it cares about: +// +// g := &fakeGit{} +// g.AddFn = func(_, _, _ string) error { return errors.New("boom") } +type fakeGit struct { + mu sync.Mutex + Calls []string // " …", in order + + AddFn func(cloneDir, worktreePath, branch string) error + AddNewBranchFn func(cloneDir, worktreePath, branch string) error + AddNewBranchFromFn func(cloneDir, worktreePath, branch, startPoint string) error + RemoveFn func(cloneDir, worktreePath string) error + ListFn func(cloneDir string) ([]git.WorktreeInfo, error) + FetchFn func(cloneDir, remote, branch string) error + FetchAllFn func(cloneDir string) error + FastForwardFn func(cloneDir, remote, branch string) error + RemotesFn func(cloneDir string) ([]string, error) + ListRemoteBranchesFn func(cloneDir string) ([]git.RemoteBranch, error) + AddTrackingFn func(cloneDir, worktreePath, branch, remoteRef string) error + HasLocalBranchFn func(cloneDir, branch string) (bool, error) + CloneFn func(repo, path, branch string) error +} + +func (g *fakeGit) record(parts ...string) { + g.mu.Lock() + defer g.mu.Unlock() + g.Calls = append(g.Calls, strings.Join(parts, " ")) +} + +// called reports whether any recorded call contains all the given substrings. +func (g *fakeGit) called(want ...string) bool { + g.mu.Lock() + defer g.mu.Unlock() + for _, c := range g.Calls { + ok := true + for _, w := range want { + if !strings.Contains(c, w) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +func (g *fakeGit) Add(cloneDir, worktreePath, branch string) error { + g.record("Add", cloneDir, worktreePath, branch) + if g.AddFn != nil { + return g.AddFn(cloneDir, worktreePath, branch) + } + return nil +} + +func (g *fakeGit) AddNewBranch(cloneDir, worktreePath, branch string) error { + g.record("AddNewBranch", cloneDir, worktreePath, branch) + if g.AddNewBranchFn != nil { + return g.AddNewBranchFn(cloneDir, worktreePath, branch) + } + return nil +} + +func (g *fakeGit) AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint string) error { + g.record("AddNewBranchFrom", cloneDir, worktreePath, branch, startPoint) + if g.AddNewBranchFromFn != nil { + return g.AddNewBranchFromFn(cloneDir, worktreePath, branch, startPoint) + } + return nil +} + +func (g *fakeGit) Remove(cloneDir, worktreePath string) error { + g.record("Remove", cloneDir, worktreePath) + if g.RemoveFn != nil { + return g.RemoveFn(cloneDir, worktreePath) + } + return nil +} + +func (g *fakeGit) List(cloneDir string) ([]git.WorktreeInfo, error) { + g.record("List", cloneDir) + if g.ListFn != nil { + return g.ListFn(cloneDir) + } + return nil, nil +} + +func (g *fakeGit) Fetch(cloneDir, remote, branch string) error { + g.record("Fetch", cloneDir, remote, branch) + if g.FetchFn != nil { + return g.FetchFn(cloneDir, remote, branch) + } + return nil +} + +func (g *fakeGit) FetchAll(cloneDir string) error { + g.record("FetchAll", cloneDir) + if g.FetchAllFn != nil { + return g.FetchAllFn(cloneDir) + } + return nil +} + +func (g *fakeGit) FastForward(cloneDir, remote, branch string) error { + g.record("FastForward", cloneDir, remote, branch) + if g.FastForwardFn != nil { + return g.FastForwardFn(cloneDir, remote, branch) + } + return nil +} + +func (g *fakeGit) Remotes(cloneDir string) ([]string, error) { + g.record("Remotes", cloneDir) + if g.RemotesFn != nil { + return g.RemotesFn(cloneDir) + } + return []string{"origin"}, nil +} + +func (g *fakeGit) ListRemoteBranches(cloneDir string) ([]git.RemoteBranch, error) { + g.record("ListRemoteBranches", cloneDir) + if g.ListRemoteBranchesFn != nil { + return g.ListRemoteBranchesFn(cloneDir) + } + return nil, nil +} + +func (g *fakeGit) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error { + g.record("AddTracking", cloneDir, worktreePath, branch, remoteRef) + if g.AddTrackingFn != nil { + return g.AddTrackingFn(cloneDir, worktreePath, branch, remoteRef) + } + return nil +} + +func (g *fakeGit) HasLocalBranch(cloneDir, branch string) (bool, error) { + g.record("HasLocalBranch", cloneDir, branch) + if g.HasLocalBranchFn != nil { + return g.HasLocalBranchFn(cloneDir, branch) + } + return false, nil +} + +func (g *fakeGit) Clone(repo, path, branch string) error { + g.record("Clone", repo, path, branch) + if g.CloneFn != nil { + return g.CloneFn(repo, path, branch) + } + return nil +} + +// fakeTmux satisfies tmux.Runner. Sessions is the raw list-sessions table it +// serves; Calls records every invocation. +type fakeTmux struct { + mu sync.Mutex + Sessions []sessionRow + Calls [][]string + RunFn func(args ...string) (string, string, int, error) // overrides everything +} + +// sessionRow is one record in the fake's list-sessions table, in the field +// order tmux.Client.ListSessions parses. +type sessionRow struct { + ID, Name, Canonical, Type, Status, Annotation, StartedAt, Profile, Branch, Project string +} + +func (r sessionRow) format() string { + return strings.Join([]string{ + r.ID, r.Name, r.Canonical, r.Type, r.Status, + r.Annotation, r.StartedAt, r.Profile, r.Branch, r.Project, + }, "\t") +} + +func (f *fakeTmux) Run(args ...string) (string, string, int, error) { + f.mu.Lock() + f.Calls = append(f.Calls, args) + f.mu.Unlock() + + if f.RunFn != nil { + return f.RunFn(args...) + } + switch args[0] { + case "list-sessions": + if len(f.Sessions) == 0 { + return "", "", 1, nil // tmux exits 1 when there are no sessions + } + rows := make([]string, len(f.Sessions)) + for i, s := range f.Sessions { + rows[i] = s.format() + } + return strings.Join(rows, "\n"), "", 0, nil + case "new-session": + return "$1", "", 0, nil + case "has-session": + return "", "", 1, nil + } + return "", "", 0, nil +} + +// called reports whether any recorded tmux invocation contains all the given +// substrings, in any position. +func (f *fakeTmux) called(want ...string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.Calls { + joined := strings.Join(c, " ") + ok := true + for _, w := range want { + if !strings.Contains(joined, w) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +// killed returns every kill-session target, in order. +func (f *fakeTmux) killed() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.Calls { + if len(c) >= 3 && c[0] == "kill-session" { + out = append(out, c[2]) + } + } + return out +} + +// mockHook records hook triggers and can fail a named one. +type mockHook struct { + calls []hookCall + failOn string +} + +type hookCall struct { + name string + attrs map[string]string + workDir string +} + +func (m *mockHook) Trigger(name string, attrs map[string]string, workDir string) error { + m.calls = append(m.calls, hookCall{name, attrs, workDir}) + if m.failOn == name { + return fmt.Errorf("hook %s failed", name) + } + return nil +} + +// withHook forces every operation on h to use the given hook, bypassing +// config lookup. This is the seam that keeps the hook tests intact. +func withHook(h *Herd, m hooks.Hook) *Herd { + h.newHook = func(config.HooksConfig) hooks.Hook { return m } + return h +} + +// TestFakeTmux_smoke validates the shared tmux fake that the session and +// worktree domains (Tasks 4-5) build on. It stays off the Sessions table and +// sessionRow.Project, which depend on the tmux SplitN widening Task 4 lands +// before any list-sessions test may set them. +func TestFakeTmux_smoke(t *testing.T) { + f := &fakeTmux{} + // A Herd routes tmux through the runner it is given. + _ = New(&config.Config{}, nil, Deps{Tmux: f}) + + row := sessionRow{ID: "$1", Name: "myapp-feat", Canonical: "myapp-feat", Type: string(SessionTypeAgent)} + if !strings.Contains(row.format(), "myapp-feat") { + t.Fatalf("format() = %q, want it to contain the session name", row.format()) + } + + if _, _, _, err := f.Run("kill-session", "-t", "$1"); err != nil { + t.Fatalf("Run: %v", err) + } + if !f.called("kill-session", "$1") { + t.Errorf("called() did not observe the kill-session invocation; calls=%v", f.Calls) + } + if got := f.killed(); len(got) != 1 || got[0] != "$1" { + t.Errorf("killed() = %v, want [$1]", got) + } +} diff --git a/internal/herd/herd.go b/internal/herd/herd.go new file mode 100644 index 0000000..3f46908 --- /dev/null +++ b/internal/herd/herd.go @@ -0,0 +1,158 @@ +// Package herd is codeherd's domain. It owns projects, worktrees, and the +// tmux sessions running in them — three things that used to be three +// packages that could not see each other. +// +// The split cost us a class of defects. The session package had no config, so +// it could not know the active profile, so every profile decision moved up +// into its callers, and one of them rebuilt a session name without the +// profile and killed nothing. Here, identity lives in one place: a Ref +// obtained from Herd.Ref always carries the profile, and every session +// lookup is keyed on a Ref. +package herd + +import ( + "fmt" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/hooks" + "github.com/xico42/codeherd/internal/semconv" + "github.com/xico42/codeherd/internal/tmux" +) + +// SessionType distinguishes the two kinds of session codeherd runs. Both are +// first-class: they coexist for the same Ref and are addressed the same way. +type SessionType string + +const ( + SessionTypeAgent SessionType = semconv.SessionTypeAgent + SessionTypeShell SessionType = semconv.SessionTypeShell +) + +// Status is an agent session's lifecycle state, stored on the tmux session. +type Status string + +const ( + StatusRunning Status = semconv.StatusRunning + StatusWaiting Status = semconv.StatusWaiting +) + +// RemoteBranch is one remote-tracking branch. Aliased rather than redeclared: +// git.Runner returns these, and a conversion loop at the exec boundary would +// buy nothing. +type RemoteBranch = git.RemoteBranch + +// Ref identifies a workspace — a project and branch, scoped to a profile. +// +// Branch is ALWAYS the identity branch: the branch the worktree was created +// for, which is what its sessions were named after. It is never the branch +// HEAD currently points at. Use Workspace.DisplayBranch for rendering. +// +// Obtain a Ref from Herd.Ref or from Workspace.Ref. Never build one by hand. +// Herd.Ref takes no profile argument, so the shortest path is the correct +// one; a hand-built herd.Ref{Project: p, Branch: b} is visibly missing a +// field under review, and that missing field is the bug this package exists +// to prevent. +type Ref struct { + Profile string + Project string + Branch string +} + +// CanonicalName is the session name frozen at creation: the identity both +// session types share, and the key every tmux lookup matches on. +func (r Ref) CanonicalName() string { + return semconv.SessionName(r.Profile, r.Project, r.Branch) +} + +// tmuxName is the actual tmux session name for a type. It differs from +// CanonicalName only for shell sessions, which carry a ~sh suffix so the two +// types can coexist. +func (r Ref) tmuxName(t SessionType) string { + if t == SessionTypeShell { + return semconv.ShellSessionName(r.Profile, r.Project, r.Branch) + } + return semconv.SessionName(r.Profile, r.Project, r.Branch) +} + +// Deps holds the exec-boundary runners. Two fields; revisit options at three. +type Deps struct { + Tmux tmux.Runner + Git git.Runner +} + +// Herd is the domain: config, the active profile, and the runners. +type Herd struct { + cfg *config.Config + profile string + profilesDir string + profiles []string + tmux *tmux.Client + git git.Runner + + // newHook builds the hook dispatcher for one project's hook config. + // + // It is a defaulted field, not a constructor parameter, and that is + // deliberate. Binding hooks at construction is what killed dependency + // injection in the TUI: the actions needed a project-bound hook, so + // every one of them rebuilt its own service and Model.sesSvc became a + // field that was assigned and never read. Herd holds cfg, so it can + // resolve hooks per operation instead. Tests override this field. + newHook func(config.HooksConfig) hooks.Hook +} + +// New builds a Herd for the given config and profile registry. A nil +// registry means profile mode is off — that is what config.Load returns in +// the common case, so New must accept it. +func New(cfg *config.Config, registry *config.ProfileRegistry, deps Deps) *Herd { + h := &Herd{ + cfg: cfg, + tmux: tmux.NewClient(deps.Tmux), + git: deps.Git, + newHook: func(hc config.HooksConfig) hooks.Hook { return hooks.New(hc) }, + } + if registry != nil { + h.profile = registry.Active + h.profilesDir = registry.ProfilesDir + h.profiles = registry.Names + } + return h +} + +// Ref supplies the active profile. This is the only sanctioned way to mint a +// Ref from a (project, branch) pair. +func (h *Herd) Ref(project, branch string) Ref { + return Ref{Profile: h.profile, Project: project, Branch: branch} +} + +// Config exposes the config this Herd was built for. Front ends need it for +// agent lookup and project enumeration. +func (h *Herd) Config() *config.Config { return h.cfg } + +// Profile returns the active profile name, or "" when profile mode is off. +func (h *Herd) Profile() string { return h.profile } + +// Profiles returns every discovered profile name, nil when profile mode is off. +func (h *Herd) Profiles() []string { return h.profiles } + +// WithProfile returns a new Herd scoped to a different profile, sharing this +// one's runners. The receiver is unchanged. +func (h *Herd) WithProfile(name string) (*Herd, error) { + if h.profilesDir == "" { + return nil, fmt.Errorf("cannot switch to profile %q: profiles are not enabled", name) + } + cfg, err := config.LoadProfile(h.profilesDir, name) + if err != nil { + return nil, fmt.Errorf("loading profile %s: %w", name, err) + } + next := *h + next.cfg = cfg + next.profile = name + return &next, nil +} + +// hookFor returns the hook dispatcher for a project. It is total: an +// unconfigured project yields a dispatcher with no hooks, which fires nothing. +func (h *Herd) hookFor(project string) hooks.Hook { + return h.newHook(h.cfg.Projects[project].Hooks) +} diff --git a/internal/herd/herd_test.go b/internal/herd/herd_test.go new file mode 100644 index 0000000..b43d7a3 --- /dev/null +++ b/internal/herd/herd_test.go @@ -0,0 +1,117 @@ +package herd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/hooks" +) + +// h.Ref takes no profile argument, so the shortest path is the correct one. +// This is the whole point of the collapse: the profile-blind Ref cannot be +// spelled without visibly hand-building the struct. +func TestRef_carriesActiveProfile(t *testing.T) { + h := New(&config.Config{}, &config.ProfileRegistry{Active: "work"}, Deps{}) + ref := h.Ref("myapp", "feat") + + if ref.Profile != "work" { + t.Errorf("Profile = %q, want %q", ref.Profile, "work") + } + if got := ref.CanonicalName(); got != "work-myapp-feat" { + t.Errorf("CanonicalName() = %q, want %q", got, "work-myapp-feat") + } +} + +// A nil registry is what config.Load returns when profiles are off. New must +// not panic on it — the spec's own §8.1 sample did. +func TestNew_nilRegistryMeansNoProfile(t *testing.T) { + h := New(&config.Config{}, nil, Deps{}) + ref := h.Ref("myapp", "feat") + + if ref.Profile != "" { + t.Errorf("Profile = %q, want empty", ref.Profile) + } + if got := ref.CanonicalName(); got != "myapp-feat" { + t.Errorf("CanonicalName() = %q, want %q", got, "myapp-feat") + } +} + +func TestRef_tmuxNameDiffersByType(t *testing.T) { + h := New(&config.Config{}, &config.ProfileRegistry{Active: "work"}, Deps{}) + ref := h.Ref("myapp", "feat/login") + + if got := ref.tmuxName(SessionTypeAgent); got != "work-myapp-feat-login" { + t.Errorf("agent tmuxName = %q", got) + } + if got := ref.tmuxName(SessionTypeShell); got != "work-myapp-feat-login~sh" { + t.Errorf("shell tmuxName = %q", got) + } +} + +func TestWithProfile_swapsConfigAndProfile(t *testing.T) { + dir := t.TempDir() + toml := "[projects.myapp]\nrepo = \"git@github.com:user/other.git\"\n" + if err := os.WriteFile(filepath.Join(dir, "home.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + reg := &config.ProfileRegistry{Active: "work", Names: []string{"work", "home"}, ProfilesDir: dir} + h := New(&config.Config{}, reg, Deps{}) + + next, err := h.WithProfile("home") + if err != nil { + t.Fatalf("WithProfile: %v", err) + } + if next.Ref("myapp", "feat").Profile != "home" { + t.Error("new Herd did not adopt the home profile") + } + if next.Config().Projects["myapp"].Repo != "git@github.com:user/other.git" { + t.Error("new Herd did not adopt the home config") + } + if h.Ref("myapp", "feat").Profile != "work" { + t.Error("WithProfile mutated the receiver; it must return a new Herd") + } +} + +func TestWithProfile_errorsWhenProfilesDisabled(t *testing.T) { + h := New(&config.Config{}, nil, Deps{}) + if _, err := h.WithProfile("work"); err == nil { + t.Fatal("want error when profiles are disabled, got nil") + } +} + +// hookFor must be total (never nil) AND thread the right project's hook +// config. A "!= nil" assertion can never fail here — hooks.New returns a +// non-nil *Service for any input, including a zero HooksConfig — so this +// overrides h.newHook with a capturing func and asserts on what was passed. +func TestHookFor_defaultsToConfiguredHooks(t *testing.T) { + myappHooks := config.HooksConfig{PreClone: "echo hi"} + cfg := &config.Config{Projects: map[string]config.ProjectConfig{ + "myapp": {Hooks: myappHooks}, + }} + h := New(cfg, nil, Deps{}) + + var got []config.HooksConfig + h.newHook = func(hc config.HooksConfig) hooks.Hook { + got = append(got, hc) + return &hooks.NoOp{} + } + + if hf := h.hookFor("myapp"); hf == nil { + t.Error("hookFor returned nil for a configured project") + } + if hf := h.hookFor("nonexistent"); hf == nil { + t.Error("hookFor returned nil for an unconfigured project; it must be total") + } + + if len(got) != 2 { + t.Fatalf("newHook called %d times, want 2", len(got)) + } + if got[0] != myappHooks { + t.Errorf("hookFor(%q) passed %+v, want %+v", "myapp", got[0], myappHooks) + } + if got[1] != (config.HooksConfig{}) { + t.Errorf("hookFor(%q) passed %+v, want zero value", "nonexistent", got[1]) + } +} diff --git a/internal/herd/integration_test.go b/internal/herd/integration_test.go new file mode 100644 index 0000000..b137551 --- /dev/null +++ b/internal/herd/integration_test.go @@ -0,0 +1,184 @@ +//go:build integration + +package herd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" +) + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +// localCloneGit is a real git runner whose Clone ignores the configured repo +// URL and clones a local remote instead, so AutoClone can be exercised offline +// while the clone dir still derives from the github-style URL. +type localCloneGit struct { + git.Runner + source string +} + +func (g localCloneGit) Clone(_, path, branch string) error { + args := []string{"clone"} + if branch != "" { + args = append(args, "-b", branch) + } + args = append(args, g.source, path) + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("clone: %v\n%s", err, out) + } + return nil +} + +// EnsureWorkspace with AutoClone must clone the project first, then create the +// new-branch worktree under __worktrees/. This guards the ordering after +// worktreePath began consulting git.List: the List call must only ever run +// against an already-cloned repo. +// +// The clone source is a synthetic upstream repo with a main branch, built here +// with real git, so the test exercises AutoClone against genuine git while +// staying hermetic — it must not depend on the outer checkout having a local +// main branch, which CI (single-branch PR checkouts) does not. +func TestEnsureWorkspace_autoClone_endToEnd(t *testing.T) { + root := t.TempDir() + + // Build an upstream repo whose default branch is main and that carries a + // go.mod, so the clone and its worktree can be asserted below. + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "go.mod"), []byte("module myapp\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "init") + + projectsDir := filepath.Join(root, "projects") + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, nil, Deps{Tmux: nil, Git: localCloneGit{Runner: git.NewRealRunner(), source: remote}}) + + // The project is not cloned yet — AutoClone must do it. + if _, err := os.Stat(cloneDir); !os.IsNotExist(err) { + t.Fatalf("clone dir should not exist before EnsureWorkspace: %v", err) + } + + ws, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{AutoClone: true}) + if err != nil { + t.Fatalf("EnsureWorkspace: %v", err) + } + + // The clone happened. + if _, err := os.Stat(filepath.Join(cloneDir, "go.mod")); err != nil { + t.Errorf("expected clone at %s: %v", cloneDir, err) + } + // The new worktree lives under __worktrees/, not at the clone dir. + wantPath := filepath.Join(cloneDir+"__worktrees", "feature") + if ws.Path != wantPath { + t.Errorf("worktree path = %q, want %q", ws.Path, wantPath) + } + if _, err := os.Stat(filepath.Join(ws.Path, "go.mod")); err != nil { + t.Errorf("expected go.mod in worktree: %v", err) + } + + // And the default branch resolves to the clone dir on the freshly cloned + // repo — the bug this change fixes, proven end-to-end. + mainPath, err := h.worktreePath(h.Ref("myapp", "main")) + if err != nil { + t.Fatalf("worktreePath(main): %v", err) + } + if mainPath != cloneDir { + t.Errorf("worktreePath(main) = %q, want clone dir %q", mainPath, cloneDir) + } +} + +func TestEnsureWorkspace_tracking_endToEnd(t *testing.T) { + root := t.TempDir() + + // Build an upstream repo with a PR branch. + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "init") + runGit(t, remote, "checkout", "-b", "feat-x") + if err := os.WriteFile(filepath.Join(remote, "feature.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "feature") + runGit(t, remote, "checkout", "main") + + // Clone into the codeherd layout: /github.com/user/myapp. + projectsDir := filepath.Join(root, "projects") + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + if err := os.MkdirAll(filepath.Dir(cloneDir), 0o755); err != nil { + t.Fatal(err) + } + runGit(t, root, "clone", remote, cloneDir) + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, nil, Deps{Tmux: nil, Git: git.NewRealRunner()}) + + ws, err := h.EnsureWorkspace(h.Ref("myapp", ""), EnsureOpts{Track: "feat-x"}) + if err != nil { + t.Fatalf("EnsureWorkspace: %v", err) + } + if ws.Ref.Branch != "feat-x" { + t.Errorf("branch = %q, want feat-x", ws.Ref.Branch) + } + if _, err := os.Stat(filepath.Join(ws.Path, "feature.txt")); err != nil { + t.Errorf("expected feature.txt in worktree: %v", err) + } + + branch := strings.TrimSpace(runGit(t, ws.Path, "rev-parse", "--abbrev-ref", "HEAD")) + if branch != "feat-x" { + t.Errorf("worktree branch = %q, want feat-x", branch) + } + upstream := strings.TrimSpace(runGit(t, ws.Path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")) + if upstream != "origin/feat-x" { + t.Errorf("upstream = %q, want origin/feat-x", upstream) + } +} diff --git a/internal/herd/matrix_integration_test.go b/internal/herd/matrix_integration_test.go new file mode 100644 index 0000000..83872d8 --- /dev/null +++ b/internal/herd/matrix_integration_test.go @@ -0,0 +1,242 @@ +//go:build integration + +package herd + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/tmux" +) + +// TestMatrix_LaunchAndResolve fills the Launch and Resolve rows of the §10 +// matrix against real tmux: a launched agent session must exist on the server +// under its (possibly profile-prefixed) canonical name, and Resolve must find +// it by the same identity Ref that created it. +func TestMatrix_LaunchAndResolve(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, _ := setupMatrixHerd(t, col.registry) + + launched, err := h.Launch(ref, LaunchOpts{}) + if err != nil { + t.Fatalf("Launch: %v", err) + } + + if !tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Fatalf("tmux server has no session %q after Launch", ref.CanonicalName()) + } + + got, err := h.Resolve(ref, SessionTypeAgent) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got.ID != launched.ID { + t.Errorf("Resolve ID = %q, want %q", got.ID, launched.ID) + } + if got.Canonical != ref.CanonicalName() { + t.Errorf("Resolve Canonical = %q, want %q", got.Canonical, ref.CanonicalName()) + } + }) + } +} + +// TestMatrix_StopSessions fills the StopSessions row: after launching both an +// agent and a shell session, StopSessions(All) must stop both, return two +// handles, and leave neither on the real tmux server. Under a profile this is +// the cell that was a gap — the pre-refactor code rebuilt a profile-blind name +// and missed the profile-prefixed session. +func TestMatrix_StopSessions(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, _ := setupMatrixHerd(t, col.registry) + + if _, err := h.Launch(ref, LaunchOpts{Type: SessionTypeAgent}); err != nil { + t.Fatalf("Launch agent: %v", err) + } + if _, err := h.Launch(ref, LaunchOpts{Type: SessionTypeShell}); err != nil { + t.Fatalf("Launch shell: %v", err) + } + + agentName := ref.CanonicalName() + shellName := ref.CanonicalName() + "~sh" // == semconv.ShellSessionName(...) + if !tmuxHasSession(t, socket, agentName) || !tmuxHasSession(t, socket, shellName) { + t.Fatalf("precondition: expected both sessions running (agent=%v shell=%v)", + tmuxHasSession(t, socket, agentName), tmuxHasSession(t, socket, shellName)) + } + + stopped, err := h.StopSessions(ref, StopOpts{All: true}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 2 { + t.Errorf("StopSessions stopped %d sessions, want 2", len(stopped)) + } + if tmuxHasSession(t, socket, agentName) { + t.Errorf("agent session %q survived StopSessions", agentName) + } + if tmuxHasSession(t, socket, shellName) { + t.Errorf("shell session %q survived StopSessions", shellName) + } + }) + } +} + +// matrixProfiles is the two columns of the §10 coverage matrix: every +// operation is exercised with profiles off and on. "off" passes a nil +// registry (profile mode disabled — what config.Load returns in the common +// case); "on" passes a registry with an active profile, so h.Ref() stamps the +// profile and every session name is prefixed (e.g. work-myapp-feat). +var matrixProfiles = []struct { + name string + registry *config.ProfileRegistry +}{ + {"profile off", nil}, + {"profile on", &config.ProfileRegistry{Active: "work"}}, +} + +// useIsolatedTmux gives the calling test a private tmux server reached via a +// socket under t.TempDir(). It sets CODEHERD_TMUX_SOCKET so the Herd's real +// tmux runner targets the same server, clears $TMUX so new-session does not +// think it is nested, probes once, and t.Skips when tmux cannot daemonize +// (missing binary or sandboxed CI). The server is killed on cleanup so the +// socket — and any sleep processes the sessions started — disappear with the +// TempDir. Returns the socket path for direct tmux assertions. +func useIsolatedTmux(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not available") + } + socket := filepath.Join(t.TempDir(), "tmux.sock") + t.Setenv(tmux.SocketEnvVar, socket) + t.Setenv("TMUX", "") + probe := exec.Command("tmux", "-S", socket, "new-session", "-d", "-s", "__probe__", "sleep", "30") + if out, err := probe.CombinedOutput(); err != nil { + t.Skipf("tmux daemonize unavailable: %v\n%s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("tmux", "-S", socket, "kill-server").Run() + }) + return socket +} + +// tmuxHasSession reports whether the isolated server has an exactly-named +// session. The "=" target prefix forces an exact match so an agent session +// (work-myapp-feat) is never confused with its shell (work-myapp-feat~sh). +func tmuxHasSession(t *testing.T, socket, name string) bool { + t.Helper() + return exec.Command("tmux", "-S", socket, "has-session", "-t", "="+name).Run() == nil +} + +// setupMatrixHerd builds a Herd wired to REAL tmux and REAL git for the given +// profile column, with the myapp project cloned and a "feat" worktree created +// on disk. It returns the Herd, the identity Ref (carrying the profile when +// the registry is non-nil), and the worktree path. +func setupMatrixHerd(t *testing.T, registry *config.ProfileRegistry) (*Herd, Ref, string) { + t.Helper() + root := t.TempDir() + + // A tiny upstream repo with a single commit on main. + remote := filepath.Join(root, "remote") + if err := os.MkdirAll(remote, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, remote, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, remote, "add", ".") + runGit(t, remote, "commit", "-m", "init") + + // Clone into the codeherd layout: /github.com/user/myapp. + projectsDir := filepath.Join(root, "projects") + cloneDir := filepath.Join(projectsDir, "github.com", "user", "myapp") + if err := os.MkdirAll(filepath.Dir(cloneDir), 0o755); err != nil { + t.Fatal(err) + } + runGit(t, root, "clone", remote, cloneDir) + + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: projectsDir, Agent: "agent"}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + Agents: map[string]config.AgentConfig{ + // A long sleep keeps the tmux session alive for the assertions. + "agent": {Cmd: "sleep", Args: []string{"300"}}, + }, + } + h := New(cfg, registry, Deps{Tmux: tmux.NewRealRunner(), Git: git.NewRealRunner()}) + + ref := h.Ref("myapp", "feat") + ws, err := h.EnsureWorkspace(ref, EnsureOpts{}) + if err != nil { + t.Fatalf("EnsureWorkspace: %v", err) + } + return h, ref, ws.Path +} + +// TestMatrix_Teardown fills the Teardown row — the row the shipped defect +// lived in. With Force, Teardown must kill the (profile-prefixed) session AND +// remove the worktree from disk. A surviving session under "profile on" is +// exactly the orphaned-agent bug the matrix exists to catch. +func TestMatrix_Teardown(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, wtPath := setupMatrixHerd(t, col.registry) + + if _, err := h.Launch(ref, LaunchOpts{}); err != nil { + t.Fatalf("Launch: %v", err) + } + if !tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Fatalf("precondition: session %q not running", ref.CanonicalName()) + } + + if err := h.Teardown(ref, TeardownOpts{Force: true}); err != nil { + t.Fatalf("Teardown: %v", err) + } + + if tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Errorf("session %q survived Teardown (orphaned agent)", ref.CanonicalName()) + } + if _, err := os.Stat(wtPath); !os.IsNotExist(err) { + t.Errorf("worktree %q still on disk after Teardown (stat err=%v)", wtPath, err) + } + }) + } +} + +// TestMatrix_TeardownRefusesRunning is the non-force half: Teardown without +// Force must refuse with ErrSessionRunning while a session is live, and must +// leave both the session and the worktree intact. +func TestMatrix_TeardownRefusesRunning(t *testing.T) { + for _, col := range matrixProfiles { + t.Run(col.name, func(t *testing.T) { + socket := useIsolatedTmux(t) + h, ref, wtPath := setupMatrixHerd(t, col.registry) + + if _, err := h.Launch(ref, LaunchOpts{}); err != nil { + t.Fatalf("Launch: %v", err) + } + + err := h.Teardown(ref, TeardownOpts{Force: false}) + if !errors.Is(err, ErrSessionRunning) { + t.Fatalf("Teardown(Force:false) err = %v, want ErrSessionRunning", err) + } + if !tmuxHasSession(t, socket, ref.CanonicalName()) { + t.Errorf("session %q was killed despite refusal", ref.CanonicalName()) + } + if _, err := os.Stat(wtPath); err != nil { + t.Errorf("worktree %q removed despite refusal: %v", wtPath, err) + } + }) + } +} diff --git a/internal/herd/paths.go b/internal/herd/paths.go new file mode 100644 index 0000000..387e47d --- /dev/null +++ b/internal/herd/paths.go @@ -0,0 +1,91 @@ +package herd + +import ( + "fmt" + "sort" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" +) + +// repoPath returns the filesystem-relative path derived from a project's repo +// URL, e.g. github.com/user/myapp. +func (h *Herd) repoPath(project string) (string, error) { + p, ok := h.cfg.Projects[project] + if !ok { + return "", fmt.Errorf("project %q is not configured", project) + } + rp, err := config.RepoPath(p.Repo) + if err != nil { + return "", fmt.Errorf("parsing repo URL %q: %w", p.Repo, err) + } + return rp, nil +} + +// cloneDir returns the main git clone directory for a project. +func (h *Herd) cloneDir(project string) (string, error) { + rp, err := h.repoPath(project) + if err != nil { + return "", err + } + return semconv.CloneDir(h.cfg.Defaults.ProjectsDir, rp), nil +} + +// worktreesRoot returns the directory holding a project's worktrees. +func (h *Herd) worktreesRoot(project string) (string, error) { + rp, err := h.repoPath(project) + if err != nil { + return "", err + } + return semconv.WorktreesRoot(h.cfg.Defaults.ProjectsDir, rp), nil +} + +// worktreePath returns the filesystem path for a ref's worktree. It derives +// from Ref.Branch — the identity branch — so it agrees with the session name +// by construction. +// +// The main worktree lives at the clone dir itself, not under __worktrees/, so +// the __worktrees/ formula is wrong for the default branch. We +// resolve against the live worktree list using the same identity function +// List/workspaceFrom use, so the operate paths and the listing can never +// disagree about where the main worktree is — feeding a listed Ref back into +// Launch or Teardown must land on the same directory List reported. When no +// live worktree matches — the worktree does not exist yet, or git is +// unavailable — we fall back to the formula, which is correct for every +// non-main branch and yields a sensible not-found path for callers that stat. +func (h *Herd) worktreePath(ref Ref) (string, error) { + rp, err := h.repoPath(ref.Project) + if err != nil { + return "", err + } + cloneDir := semconv.CloneDir(h.cfg.Defaults.ProjectsDir, rp) + if h.git != nil { + if infos, err := h.git.List(cloneDir); err == nil { + defaultBranch := h.cfg.Projects[ref.Project].DefaultBranch + for _, wt := range infos { + identity := semconv.WorktreeIdentityBranch(wt.Path, cloneDir, defaultBranch, wt.Branch) + if semconv.FlattenBranch(identity) == semconv.FlattenBranch(ref.Branch) { + return wt.Path, nil + } + } + } + } + return semconv.WorktreePath(h.cfg.Defaults.ProjectsDir, rp, ref.Branch), nil +} + +// projectNames returns sorted project names, or just the named one after +// validating it exists. +func (h *Herd) projectNames(project string) ([]string, error) { + if project != "" { + if _, ok := h.cfg.Projects[project]; !ok { + return nil, fmt.Errorf("project %q is not configured", project) + } + return []string{project}, nil + } + names := make([]string, 0, len(h.cfg.Projects)) + for name := range h.cfg.Projects { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} diff --git a/internal/herd/paths_test.go b/internal/herd/paths_test.go new file mode 100644 index 0000000..f6c5a21 --- /dev/null +++ b/internal/herd/paths_test.go @@ -0,0 +1,142 @@ +package herd + +import ( + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" +) + +func pathsHerd(t *testing.T) (*Herd, string) { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + return New(cfg, nil, Deps{}), dir +} + +func TestCloneDir_derivedFromRepoURL(t *testing.T) { + h, dir := pathsHerd(t) + got, err := h.cloneDir("myapp") + if err != nil { + t.Fatalf("cloneDir: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp") + if got != want { + t.Errorf("cloneDir = %q, want %q", got, want) + } +} + +func TestWorktreesRoot_derivedFromRepoURL(t *testing.T) { + h, dir := pathsHerd(t) + got, err := h.worktreesRoot("myapp") + if err != nil { + t.Fatalf("worktreesRoot: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp__worktrees") + if got != want { + t.Errorf("worktreesRoot = %q, want %q", got, want) + } +} + +func TestWorktreePath_flattensBranch(t *testing.T) { + h, dir := pathsHerd(t) + got, err := h.worktreePath(h.Ref("myapp", "feat/login")) + if err != nil { + t.Fatalf("worktreePath: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat-login") + if got != want { + t.Errorf("worktreePath = %q, want %q", got, want) + } +} + +// The main worktree lives at the clone dir itself, not under __worktrees/. +// worktreePath must resolve the default-branch identity to the clone dir so +// operations on the main worktree (e.g. launching a shell) find it on disk. +func TestWorktreePath_defaultBranchResolvesToCloneDir(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + cloneDir := filepath.Join(dir, "github.com", "user", "myapp") + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{ + {Path: cloneDir, Branch: "main"}, + {Path: filepath.Join(cloneDir+"__worktrees", "feat"), Branch: "feat"}, + }, nil + }} + h := New(cfg, nil, Deps{Git: g}) + + got, err := h.worktreePath(h.Ref("myapp", "main")) + if err != nil { + t.Fatalf("worktreePath: %v", err) + } + if got != cloneDir { + t.Errorf("worktreePath(main) = %q, want clone dir %q", got, cloneDir) + } +} + +// With no default_branch configured, the main worktree's identity is the +// branch its HEAD is on. worktreePath must still resolve it to the clone dir. +func TestWorktreePath_unconfiguredDefaultResolvesToCloneDir(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }, + } + cloneDir := filepath.Join(dir, "github.com", "user", "myapp") + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{{Path: cloneDir, Branch: "master"}}, nil + }} + h := New(cfg, nil, Deps{Git: g}) + + got, err := h.worktreePath(h.Ref("myapp", "master")) + if err != nil { + t.Fatalf("worktreePath: %v", err) + } + if got != cloneDir { + t.Errorf("worktreePath(master) = %q, want clone dir %q", got, cloneDir) + } +} + +func TestPaths_unconfiguredProject(t *testing.T) { + h, _ := pathsHerd(t) + if _, err := h.cloneDir("nope"); err == nil { + t.Error("want error for unconfigured project, got nil") + } +} + +func TestProjectNames_sortedOrAll(t *testing.T) { + h, _ := pathsHerd(t) + h.cfg.Projects["alpha"] = config.ProjectConfig{Repo: "git@github.com:user/alpha.git"} + + all, err := h.projectNames("") + if err != nil { + t.Fatalf("projectNames(\"\"): %v", err) + } + if len(all) != 2 || all[0] != "alpha" || all[1] != "myapp" { + t.Errorf("projectNames(\"\") = %v, want [alpha myapp]", all) + } + + one, err := h.projectNames("myapp") + if err != nil { + t.Fatalf("projectNames(\"myapp\"): %v", err) + } + if len(one) != 1 || one[0] != "myapp" { + t.Errorf("projectNames(\"myapp\") = %v", one) + } + if _, err := h.projectNames("nope"); err == nil { + t.Error("want error for unconfigured project, got nil") + } +} diff --git a/internal/herd/project.go b/internal/herd/project.go new file mode 100644 index 0000000..55848b8 --- /dev/null +++ b/internal/herd/project.go @@ -0,0 +1,83 @@ +package herd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" +) + +// Project is a configured project with its derived clone path. +type Project struct { + Name string + Config config.ProjectConfig + Path string // absolute path derived from repo URL + projects_dir + Cloned bool // true if Path exists on the filesystem +} + +// Projects returns every configured project sorted by name. It does not touch +// the filesystem, so Cloned is always false — use Project for that. +func (h *Herd) Projects() []Project { + names, _ := h.projectNames("") // "" cannot error + entries := make([]Project, 0, len(names)) + for _, name := range names { + path, _ := h.cloneDir(name) // unparseable repo URL yields an empty path + entries = append(entries, Project{ + Name: name, + Config: h.cfg.Projects[name], + Path: path, + }) + } + return entries +} + +// Project returns one project including its Cloned status. +func (h *Herd) Project(name string) (Project, error) { + path, err := h.cloneDir(name) + if err != nil { + return Project{}, err + } + _, statErr := os.Stat(path) + return Project{ + Name: name, + Config: h.cfg.Projects[name], + Path: path, + Cloned: statErr == nil, + }, nil +} + +// Clone clones a project's repo into its derived path under projects_dir. +// Returns *AlreadyClonedError (wrapping ErrAlreadyCloned) if the path exists. +func (h *Herd) Clone(project string) error { + path, err := h.cloneDir(project) + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + return &AlreadyClonedError{Path: path} + } + + p := h.cfg.Projects[project] + hook := h.hookFor(project) + attrs := map[string]string{ + semconv.HookAttrProject: project, + semconv.HookAttrRepo: p.Repo, + semconv.HookAttrCloneDir: path, + } + + if err := hook.Trigger(semconv.HookPreClone, attrs, h.cfg.Defaults.ProjectsDir); err != nil { + return fmt.Errorf("pre-clone hook: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating parent directories: %w", err) + } + if err := h.git.Clone(p.Repo, path, p.DefaultBranch); err != nil { + return fmt.Errorf("cloning repository: %w", err) + } + if err := hook.Trigger(semconv.HookPostClone, attrs, h.cfg.Defaults.ProjectsDir); err != nil { + return fmt.Errorf("post-clone hook: %w", err) + } + return nil +} diff --git a/internal/herd/project_test.go b/internal/herd/project_test.go new file mode 100644 index 0000000..46ac1db --- /dev/null +++ b/internal/herd/project_test.go @@ -0,0 +1,296 @@ +package herd + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" +) + +// projectHerd builds a Herd (profiles off) for the project-domain tests, +// returning the Herd and the fakeGit its Clone calls land on. +func projectHerd(t *testing.T, projectsDir string, projects map[string]config.ProjectConfig) (*Herd, *fakeGit) { + t.Helper() + cfg := &config.Config{} + cfg.Defaults.ProjectsDir = projectsDir + cfg.Projects = projects + g := &fakeGit{} + return New(cfg, nil, Deps{Git: g}), g +} + +func TestList_SortedByName(t *testing.T) { + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{ + "zebra": {Repo: "git@github.com:user/zebra.git", DefaultBranch: "main"}, + "alpha": {Repo: "git@github.com:user/alpha.git", DefaultBranch: "develop"}, + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + entries := h.Projects() + + if len(entries) != 3 { + t.Fatalf("got %d entries, want 3", len(entries)) + } + if entries[0].Name != "alpha" || entries[1].Name != "myapp" || entries[2].Name != "zebra" { + t.Errorf("wrong order: %v", []string{entries[0].Name, entries[1].Name, entries[2].Name}) + } +} + +func TestList_PathDerivedFromRepo(t *testing.T) { + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + entries := h.Projects() + + want := "/home/user/projects/github.com/user/myapp" + if entries[0].Path != want { + t.Errorf("Path = %q, want %q", entries[0].Path, want) + } +} + +func TestList_ClonedAlwaysFalse(t *testing.T) { + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + entries := h.Projects() + if entries[0].Cloned { + t.Error("Projects should not check filesystem; Cloned should be false") + } +} + +func TestList_Empty(t *testing.T) { + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{}) + entries := h.Projects() + if len(entries) != 0 { + t.Errorf("got %d entries, want 0", len(entries)) + } +} + +func TestShow_ValidProject(t *testing.T) { + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }) + e, err := h.Project("myapp") + if err != nil { + t.Fatalf("Project() error = %v", err) + } + if e.Name != "myapp" { + t.Errorf("Name = %q, want %q", e.Name, "myapp") + } + if e.Path != "/home/user/projects/github.com/user/myapp" { + t.Errorf("Path = %q", e.Path) + } + // Cloned=false because path doesn't exist on this machine in tests +} + +func TestShow_UnknownProject(t *testing.T) { + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{}) + _, err := h.Project("nonexistent") + if err == nil { + t.Fatal("expected error for unknown project") + } +} + +func TestShow_ClonedTrue_WhenPathExists(t *testing.T) { + dir := t.TempDir() + h, _ := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + // Create the expected path so Cloned=true + expectedPath := dir + "/github.com/user/myapp" + if err := os.MkdirAll(expectedPath, 0o755); err != nil { + t.Fatal(err) + } + e, err := h.Project("myapp") + if err != nil { + t.Fatalf("Project() error = %v", err) + } + if !e.Cloned { + t.Error("Cloned should be true when path exists") + } +} + +func TestClone_HappyPath(t *testing.T) { + dir := t.TempDir() + h, g := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }) + if err := h.Clone("myapp"); err != nil { + t.Fatalf("Clone() error = %v", err) + } + want := dir + "/github.com/user/myapp" + if !g.called("Clone", "git@github.com:user/myapp.git", want, "main") { + t.Errorf("clone did not target %s with branch main; calls=%v", want, g.Calls) + } +} + +func TestClone_NoBranch(t *testing.T) { + dir := t.TempDir() + h, g := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + if err := h.Clone("myapp"); err != nil { + t.Fatalf("Clone() error = %v", err) + } + // The recorded call ends with an empty branch field. + if len(g.Calls) != 1 || g.Calls[0] != "Clone git@github.com:user/myapp.git "+dir+"/github.com/user/myapp " { + t.Errorf("Branch should be empty when default_branch not set; calls=%v", g.Calls) + } +} + +func TestClone_AlreadyCloned(t *testing.T) { + dir := t.TempDir() + h, g := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + // Pre-create the target path + targetPath := dir + "/github.com/user/myapp" + if err := os.MkdirAll(targetPath, 0o755); err != nil { + t.Fatal(err) + } + err := h.Clone("myapp") + if !errors.Is(err, ErrAlreadyCloned) { + t.Fatalf("want ErrAlreadyCloned, got %v", err) + } + if g.called("Clone") { + t.Error("git should not be called when path already exists") + } + var ace *AlreadyClonedError + if !errors.As(err, &ace) { + t.Fatal("want *AlreadyClonedError") + } + if ace.Path != targetPath { + t.Errorf("AlreadyClonedError.Path = %q, want %q", ace.Path, targetPath) + } +} + +func TestClone_UnknownProject(t *testing.T) { + h, _ := projectHerd(t, "/tmp", map[string]config.ProjectConfig{}) + err := h.Clone("nonexistent") + if err == nil { + t.Fatal("expected error for unknown project") + } +} + +func TestClone_GitFailure(t *testing.T) { + dir := t.TempDir() + h, g := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + g.CloneFn = func(repo, path, branch string) error { + return errors.New("repository not found") + } + err := h.Clone("myapp") + if err == nil { + t.Fatal("expected error on git failure") + } +} + +// ── AlreadyClonedError ──────────────────────────────────────────────────────── + +func TestAlreadyClonedError_ErrorString(t *testing.T) { + err := &AlreadyClonedError{Path: "/some/path"} + want := "/some/path already exists, skipping" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } +} + +func TestAlreadyClonedError_Unwrap(t *testing.T) { + err := &AlreadyClonedError{Path: "/some/path"} + if err.Unwrap() != ErrAlreadyCloned { + t.Errorf("Unwrap() = %v, want ErrAlreadyCloned", err.Unwrap()) + } +} + +// ── Show with bad repo URL ──────────────────────────────────────────────────── + +func TestShow_BadRepoURL(t *testing.T) { + // An https URL with no host triggers RepoPath's "no host" error. + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{ + "badrepo": {Repo: "https:///no-host/repo.git"}, + }) + _, err := h.Project("badrepo") + if err == nil { + t.Fatal("Project() with bad repo URL = nil, want error") + } +} + +// ── Clone with bad repo URL ─────────────────────────────────────────────────── + +func TestClone_BadRepoURL(t *testing.T) { + // An https URL with no host triggers RepoPath's "no host" error. + h, _ := projectHerd(t, "/home/user/projects", map[string]config.ProjectConfig{ + "badrepo": {Repo: "https:///no-host/repo.git"}, + }) + err := h.Clone("badrepo") + if err == nil { + t.Fatal("Clone() with bad repo URL = nil, want error") + } +} + +// ── Hook integration ────────────────────────────────────────────────────────── + +func TestClone_TriggersHooks(t *testing.T) { + dir := t.TempDir() + h, _ := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }) + hookMock := &mockHook{} + withHook(h, hookMock) + if err := h.Clone("myapp"); err != nil { + t.Fatalf("Clone() error = %v", err) + } + + if len(hookMock.calls) != 2 { + t.Fatalf("expected 2 hook calls, got %d", len(hookMock.calls)) + } + if hookMock.calls[0].name != semconv.HookPreClone { + t.Errorf("first hook = %q, want %q", hookMock.calls[0].name, semconv.HookPreClone) + } + if hookMock.calls[1].name != semconv.HookPostClone { + t.Errorf("second hook = %q, want %q", hookMock.calls[1].name, semconv.HookPostClone) + } + if hookMock.calls[0].attrs[semconv.HookAttrProject] != "myapp" { + t.Errorf("project attr = %q", hookMock.calls[0].attrs[semconv.HookAttrProject]) + } +} + +func TestClone_PreHookFailure_StopsClone(t *testing.T) { + dir := t.TempDir() + h, g := projectHerd(t, dir, map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git"}, + }) + hookMock := &mockHook{failOn: semconv.HookPreClone} + withHook(h, hookMock) + err := h.Clone("myapp") + if err == nil { + t.Error("expected error when pre-clone hook fails") + } + if g.called("Clone") { + t.Error("git clone should not be called when pre-clone hook fails") + } +} + +// Clone is reachable through the same Herd the rest of the domain uses, and it +// clones through the active profile's config. +func TestClone_underProfile_usesProfileConfig(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "trunk"}, + }, + } + g := &fakeGit{} + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Git: g}) + + if err := h.Clone("myapp"); err != nil { + t.Fatalf("Clone: %v", err) + } + want := filepath.Join(dir, "github.com", "user", "myapp") + if !g.called("Clone", "git@github.com:user/myapp.git", want, "trunk") { + t.Errorf("clone did not target %s; calls=%v", want, g.Calls) + } +} diff --git a/internal/herd/session.go b/internal/herd/session.go new file mode 100644 index 0000000..7645a9e --- /dev/null +++ b/internal/herd/session.go @@ -0,0 +1,339 @@ +package herd + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" + "github.com/xico42/codeherd/internal/tmux" +) + +// Handle is a live session. +type Handle struct { + ID string // tmux session_id ("$1") — stable across renames + Canonical string // @codeherd_canonical_name — the frozen identity, the match key + Ref Ref + Type SessionType + TmuxName string // current tmux name; may carry the ⚡ status prefix + Status Status + Annotation string + StartedAt time.Time +} + +// LaunchOpts configures a session start. The zero value starts the default +// agent, detached. +type LaunchOpts struct { + Type SessionType // zero value means SessionTypeAgent + Agent string // agent name; "" means defaults.agent. Ignored for shell. + Attach bool // front ends read Handle.ID and attach themselves +} + +// StopOpts selects which of a Ref's sessions to stop. +type StopOpts struct { + Type SessionType // ignored when All is true + All bool // stop every type for this Ref +} + +// Launch starts a detached tmux session for ref and returns its handle. +// +// The session command runs with these env vars, which override conflicting +// keys in the agent's configured Env: +// +// - CODEHERD_SESSION canonical session name +// - CODEHERD_PROJECT project name +// - CODEHERD_BRANCH identity branch +// - CODEHERD_CLONE_DIR main git clone path +// - CODEHERD_WORKTREE_PATH worktree root +// - CODEHERD_PROFILE profile name (only when a profile is active) +// +// Returns *SessionExistsError if a session for this ref and type is already +// running, and ErrPathNotFound if the worktree does not exist on disk. +func (h *Herd) Launch(ref Ref, opts LaunchOpts) (Handle, error) { + if opts.Type == "" { + opts.Type = SessionTypeAgent + } + + // Scope the existence check to (ref, type) so agent and shell sessions coexist. + switch _, err := h.Resolve(ref, opts.Type); { + case err == nil: + return Handle{}, &SessionExistsError{Ref: ref, Type: opts.Type} + case !errors.Is(err, ErrSessionNotFound): + return Handle{}, err + } + + path, err := h.worktreePath(ref) + if err != nil { + return Handle{}, err + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return Handle{}, fmt.Errorf("%w: %s", ErrPathNotFound, path) + } + return Handle{}, fmt.Errorf("checking worktree path: %w", err) + } + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return Handle{}, err + } + + cmd, env, err := h.sessionCommand(opts) + if err != nil { + return Handle{}, err + } + + canonical := ref.CanonicalName() + hook := h.hookFor(ref.Project) + attrs := map[string]string{ + semconv.HookAttrProject: ref.Project, + semconv.HookAttrBranch: ref.Branch, + semconv.HookAttrWorktreePath: path, + semconv.HookAttrSessionName: canonical, + } + if err := hook.Trigger(semconv.HookPreSession, attrs, path); err != nil { + return Handle{}, fmt.Errorf("pre-session hook: %w", err) + } + + sessionEnv := make(map[string]string, len(env)+6) + for k, v := range env { + sessionEnv[k] = v + } + // Codeherd-stamped vars win over user-supplied Env. + sessionEnv[semconv.SessionEnvVar] = canonical + sessionEnv[semconv.HookAttrProject] = ref.Project + sessionEnv[semconv.HookAttrBranch] = ref.Branch + sessionEnv[semconv.HookAttrWorktreePath] = path + if cloneDir != "" { + sessionEnv[semconv.HookAttrCloneDir] = cloneDir + } + if ref.Profile != "" { + sessionEnv[semconv.EnvProfile] = ref.Profile + } + + // Capture the session ID atomically at creation; a separate + // display-message round-trip would race with short-lived commands. + tmuxName := ref.tmuxName(opts.Type) + id, err := h.tmux.NewSessionWithEnv(tmuxName, path, sessionEnv, cmd) + if err != nil { + return Handle{}, fmt.Errorf("creating tmux session: %w", err) + } + + now := time.Now().UTC() + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionStatus, semconv.StatusRunning) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionStartedAt, now.Format(time.RFC3339)) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionCanonicalName, canonical) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionSessionType, string(opts.Type)) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionBranch, ref.Branch) + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionProject, ref.Project) + if ref.Profile != "" { + _ = h.tmux.SetOption(tmuxName, semconv.TmuxOptionProfile, ref.Profile) + } + + if err := hook.Trigger(semconv.HookPostSession, attrs, path); err != nil { + return Handle{}, fmt.Errorf("post-session hook: %w", err) + } + + return Handle{ + ID: id, + Ref: ref, + Type: opts.Type, + TmuxName: tmuxName, + Status: StatusRunning, + StartedAt: now, + }, nil +} + +// sessionCommand resolves the command and env a session runs with. A shell +// session runs $SHELL; an agent session runs its configured command. +func (h *Herd) sessionCommand(opts LaunchOpts) (cmd string, env map[string]string, err error) { + if opts.Type == SessionTypeShell { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + return shell, nil, nil + } + name := opts.Agent + if name == "" { + name = h.cfg.Defaults.Agent + } + if name == "" { + return "", nil, fmt.Errorf("no agent specified; use --agent or set defaults.agent in config") + } + agent, err := h.cfg.AgentByName(name) + if err != nil { + return "", nil, fmt.Errorf("resolving agent: %w", err) + } + return agent.Command(), agent.Env, nil +} + +// Resolve returns the live handle for a ref and type. +// Returns ErrSessionNotFound if no such session is running. +func (h *Herd) Resolve(ref Ref, t SessionType) (Handle, error) { + if t == "" { + t = SessionTypeAgent + } + all, err := h.handles() + if err != nil { + return Handle{}, err + } + canonical := ref.CanonicalName() + for _, hd := range all { + if hd.Canonical == canonical && hd.Type == t { + return hd, nil + } + } + return Handle{}, fmt.Errorf("%w: %s (%s)", ErrSessionNotFound, canonical, t) +} + +// Sessions returns every live session belonging to the active profile. With +// no active profile, that is every session codeherd started. +func (h *Herd) Sessions() ([]Handle, error) { + all, err := h.handles() + if err != nil { + return nil, err + } + var out []Handle + for _, hd := range all { + if hd.Ref.Profile == h.profile { + out = append(out, hd) + } + } + return out, nil +} + +// StopSessions kills the sessions matching ref and returns the handles it +// stopped. Sessions are killed by tmux session ID, never by a rebuilt name. +// Stopping nothing is not an error — Teardown calls this unconditionally. +func (h *Herd) StopSessions(ref Ref, opts StopOpts) ([]Handle, error) { + all, err := h.handles() + if err != nil { + return nil, err + } + if opts.Type == "" && !opts.All { + opts.Type = SessionTypeAgent + } + + canonical := ref.CanonicalName() + var stopped []Handle + for _, hd := range all { + if hd.Canonical != canonical { + continue + } + if !opts.All && hd.Type != opts.Type { + continue + } + if err := h.tmux.KillSession(hd.ID); err != nil { + return stopped, fmt.Errorf("killing session %s: %w", hd.Canonical, err) + } + stopped = append(stopped, hd) + } + return stopped, nil +} + +// SetStatus transitions an agent session's status and annotation, addressing +// it by canonical name. +// +// This is the one operation that does not take a Ref, and it is deliberate: +// `ch plugin handle-claude` receives a bare name from $CODEHERD_SESSION and +// cannot recover a Ref from it — the profile prefix is ambiguous, since +// work-myapp-feat could be profile "work" + project "myapp", or a project +// literally named "work-myapp". One narrow escape hatch beats re-exporting +// name resolution. +// +// Errors are suppressed: a hook must never fail the agent it is reporting on. +func (h *Herd) SetStatus(canonicalName string, status Status, annotation string) error { + if canonicalName == "" { + return nil + } + if status != StatusRunning && status != StatusWaiting { + return nil + } + + records, _ := h.tmux.ListSessions() + actualName := "" + for _, r := range records { + if r.CanonicalName == canonicalName && SessionType(r.SessionType) == SessionTypeAgent { + actualName = r.Name + break + } + } + if actualName == "" { + return nil // session not found — suppress + } + + _ = h.tmux.SetOption(actualName, semconv.TmuxOptionStatus, string(status)) + _ = h.tmux.SetOption(actualName, semconv.TmuxOptionAnnotation, annotation) + + hasPrefix := strings.HasPrefix(actualName, semconv.StatusPrefix) + if status == StatusRunning && hasPrefix { + _ = h.tmux.RenameSession(actualName, strings.TrimPrefix(actualName, semconv.StatusPrefix)) + } else if status != StatusRunning && !hasPrefix { + _ = h.tmux.RenameSession(actualName, semconv.StatusPrefix+actualName) + } + return nil +} + +// handles lists every codeherd session tmux knows about, across all profiles. +// It is the single place a tmux record becomes a Handle, which is why the +// backward-compat project recovery and self-heal live in handleFrom: every +// read path funnels through here, so none of them can disagree about identity. +func (h *Herd) handles() ([]Handle, error) { + records, err := h.tmux.ListSessions() + if err != nil { + return nil, fmt.Errorf("listing tmux sessions: %w", err) + } + out := make([]Handle, 0, len(records)) + for _, r := range records { + if r.CanonicalName == "" { + continue // not a codeherd session + } + out = append(out, h.handleFrom(r)) + } + return out, nil +} + +// resolveProject finds the configured project whose canonical session name +// matches the stored one, given the (stored) profile and branch. Profile and +// branch are known exactly, so the project is the only unknown and the match +// is unambiguous. It validates against real config rather than string- +// splitting the name, so a project no longer in config yields "", false. +func resolveProject(cfg *config.Config, profile, branch, canonical string) (string, bool) { + for name := range cfg.Projects { + if semconv.SessionName(profile, name, branch) == canonical { + return name, true + } + } + return "", false +} + +func (h *Herd) handleFrom(r tmux.SessionRecord) Handle { + hd := Handle{ + ID: r.ID, + Canonical: r.CanonicalName, + Ref: Ref{Profile: r.Profile, Project: r.Project, Branch: r.Branch}, + Type: SessionType(r.SessionType), + TmuxName: r.Name, + Status: Status(r.Status), + Annotation: r.Annotation, + } + if r.StartedAt != "" { + hd.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) + } + + // Backward compatibility: sessions created before @codeherd_project existed + // carry no project stamp. Recover it from the frozen canonical name and + // stamp it, so the session heals to first-class on first observation. + // Idempotent — once stamped, future reads take r.Project directly and skip + // this path. + if r.Project == "" && r.CanonicalName != "" { + if project, ok := resolveProject(h.cfg, r.Profile, r.Branch, r.CanonicalName); ok { + hd.Ref.Project = project + _ = h.tmux.SetOption(r.Name, semconv.TmuxOptionProject, project) + } + } + return hd +} diff --git a/internal/herd/session_test.go b/internal/herd/session_test.go new file mode 100644 index 0000000..c87805e --- /dev/null +++ b/internal/herd/session_test.go @@ -0,0 +1,896 @@ +package herd + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/semconv" +) + +// sessionHerd builds a Herd wired to the given tmux fake, with one configured +// project "myapp" and a default "claude" agent. It returns the projects_dir so +// tests can materialize the worktree path Launch derives (and stats) from the +// Ref. +func sessionHerd(t *testing.T, f *fakeTmux) (*Herd, string) { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir, Agent: "claude"}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + Agents: map[string]config.AgentConfig{"claude": {Cmd: "claude"}}, + } + return New(cfg, nil, Deps{Tmux: f, Git: &fakeGit{}}), dir +} + +// mkMyappWorktree creates the on-disk worktree path Launch derives for +// project "myapp" and the given branch, so the os.Stat check passes. +func mkMyappWorktree(t *testing.T, dir, branch string) { + t.Helper() + p := filepath.Join(dir, "github.com", "user", "myapp__worktrees", semconv.FlattenBranch(branch)) + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } +} + +// newSessionEnv extracts the KEY=VALUE pairs passed via -e flags to the +// new-session call, or nil if no such call was recorded. +func newSessionEnv(calls [][]string) map[string]string { + for _, c := range calls { + if len(c) == 0 || c[0] != "new-session" { + continue + } + env := map[string]string{} + for i := 0; i < len(c)-1; i++ { + if c[i] != "-e" { + continue + } + kv := c[i+1] + eq := strings.Index(kv, "=") + if eq < 0 { + continue + } + env[kv[:eq]] = kv[eq+1:] + } + return env + } + return nil +} + +func TestLaunch_OK(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + handle, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if err != nil { + t.Fatalf("Launch() error = %v", err) + } + if handle.ID != "$1" { + t.Errorf("Launch() ID = %q, want $1", handle.ID) + } + if handle.Type != SessionTypeAgent { + t.Errorf("Launch() Type = %q, want agent", handle.Type) + } + if handle.Ref != (Ref{Project: "myapp", Branch: "feature"}) { + t.Errorf("Launch() Ref = %+v", handle.Ref) + } +} + +func TestLaunch_StampsBranchOption(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature/login") + + if _, err := h.Launch(h.Ref("myapp", "feature/login"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + if !f.called("set-option", semconv.TmuxOptionBranch, "feature/login") { + t.Errorf("expected set-option %s feature/login; calls=%v", semconv.TmuxOptionBranch, f.Calls) + } +} + +// Launch stamps the project so Sessions can rebuild a complete Ref. +func TestLaunch_stampsProjectOption(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + if err := os.MkdirAll(filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat"), 0o755); err != nil { + t.Fatal(err) + } + + if _, err := h.Launch(h.Ref("myapp", "feat"), LaunchOpts{}); err != nil { + t.Fatalf("Launch: %v", err) + } + if !f.called("set-option", semconv.TmuxOptionProject, "myapp") { + t.Errorf("@codeherd_project was not stamped; calls=%v", f.Calls) + } +} + +func TestLaunch_DuplicateSession(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "running", Branch: "feature", Project: "myapp"}, + }} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if !errors.Is(err, ErrSessionExists) { + t.Errorf("error = %v, want ErrSessionExists", err) + } +} + +func TestLaunch_DuplicateSession_Prefixed(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "⚡ myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "waiting", Branch: "feature", Project: "myapp"}, + }} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if !errors.Is(err, ErrSessionExists) { + t.Errorf("error = %v, want ErrSessionExists", err) + } +} + +func TestLaunch_MissingPath(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) // worktree path deliberately not created + + _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if !errors.Is(err, ErrPathNotFound) { + t.Errorf("error = %v, want ErrPathNotFound", err) + } +} + +func TestLaunch_StatError(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + // A NUL byte in the branch yields a worktree path that os.Stat rejects + // with EINVAL, not IsNotExist — a different error path than ErrPathNotFound. + _, err := h.Launch(h.Ref("myapp", "\x00invalid"), LaunchOpts{}) + if err == nil { + t.Fatal("expected error for invalid path") + } + if errors.Is(err, ErrPathNotFound) { + t.Error("got ErrPathNotFound, expected a different error for invalid path") + } +} + +func TestLaunch_ListError(t *testing.T) { + f := &fakeTmux{RunFn: func(_ ...string) (string, string, int, error) { + return "", "", -1, errors.New("tmux exec failed") + }} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if err == nil { + t.Fatal("expected error when runner fails") + } +} + +func TestLaunch_newSessionError(t *testing.T) { + f := &fakeTmux{RunFn: func(args ...string) (string, string, int, error) { + switch args[0] { + case "list-sessions": + return "", "", 1, nil // no sessions + case "new-session": + return "", "tmux failed", 1, nil + } + return "", "", 0, nil + }} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if err == nil { + t.Fatal("expected error when new-session fails") + } +} + +func TestLaunch_noDefaultAgent(t *testing.T) { + f := &fakeTmux{} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, // no default agent + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, nil, Deps{Tmux: f, Git: &fakeGit{}}) + mkMyappWorktree(t, dir, "feature") + + _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}) + if err == nil || !strings.Contains(err.Error(), "no agent specified") { + t.Errorf("error = %v, want 'no agent specified'", err) + } +} + +func TestLaunch_TriggersHooks(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + hookMock := &mockHook{} + withHook(h, hookMock) + + if _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + if len(hookMock.calls) != 2 { + t.Fatalf("expected 2 hook calls, got %d", len(hookMock.calls)) + } + if hookMock.calls[0].name != semconv.HookPreSession { + t.Errorf("first hook = %q, want %q", hookMock.calls[0].name, semconv.HookPreSession) + } + if hookMock.calls[1].name != semconv.HookPostSession { + t.Errorf("second hook = %q, want %q", hookMock.calls[1].name, semconv.HookPostSession) + } +} + +func TestLaunch_writesProfileOption_whenSet(t *testing.T) { + f := &fakeTmux{} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir, Agent: "claude"}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + Agents: map[string]config.AgentConfig{"claude": {Cmd: "claude"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + mkMyappWorktree(t, dir, "feature") + + if _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + if !f.called("new-session", "-s", "work-myapp-feature") { + t.Errorf("expected new-session on work-myapp-feature; got %v", f.Calls) + } + if !f.called("set-option", "work-myapp-feature", semconv.TmuxOptionProfile, "work") { + t.Errorf("expected set-option @codeherd_profile work; got %v", f.Calls) + } +} + +func TestLaunch_emptyProfile_noProfileOptionWritten(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + if _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + if !f.called("new-session", "-s", "myapp-feature") { + t.Errorf("expected new-session on myapp-feature (no prefix); got %v", f.Calls) + } + for _, c := range f.Calls { + joined := strings.Join(c, " ") + if strings.Contains(joined, "set-option") && strings.Contains(joined, semconv.TmuxOptionProfile) { + t.Errorf("unexpected set-option @codeherd_profile call: %v", c) + } + } +} + +func TestLaunch_StampsCodeherdEnvVars(t *testing.T) { + f := &fakeTmux{} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir, Agent: "claude"}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + Agents: map[string]config.AgentConfig{"claude": {Cmd: "claude", Env: map[string]string{"USER_VAR": "user-value"}}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + mkMyappWorktree(t, dir, "feature/x") + + if _, err := h.Launch(h.Ref("myapp", "feature/x"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + + env := newSessionEnv(f.Calls) + if env == nil { + t.Fatalf("no new-session call recorded; calls=%v", f.Calls) + } + wantClone := filepath.Join(dir, "github.com", "user", "myapp") + wantPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feature-x") + want := map[string]string{ + "USER_VAR": "user-value", + semconv.SessionEnvVar: "work-myapp-feature-x", + semconv.HookAttrProject: "myapp", + semconv.HookAttrBranch: "feature/x", + semconv.HookAttrWorktreePath: wantPath, + semconv.HookAttrCloneDir: wantClone, + semconv.EnvProfile: "work", + } + for k, v := range want { + if env[k] != v { + t.Errorf("env[%q] = %q, want %q", k, env[k], v) + } + } +} + +func TestLaunch_CodeherdEnvWinsOverUserEnv(t *testing.T) { + f := &fakeTmux{} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir, Agent: "claude"}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + Agents: map[string]config.AgentConfig{"claude": {Cmd: "claude", Env: map[string]string{ + semconv.HookAttrProject: "evil-project", + semconv.HookAttrBranch: "evil-branch", + semconv.HookAttrWorktreePath: "/evil/path", + semconv.HookAttrCloneDir: "/evil/clone", + semconv.SessionEnvVar: "evil-session", + }}}, + } + h := New(cfg, nil, Deps{Tmux: f, Git: &fakeGit{}}) + mkMyappWorktree(t, dir, "feature") + + if _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + env := newSessionEnv(f.Calls) + checks := map[string]string{ + semconv.HookAttrProject: "myapp", + semconv.HookAttrBranch: "feature", + semconv.HookAttrWorktreePath: filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feature"), + semconv.HookAttrCloneDir: filepath.Join(dir, "github.com", "user", "myapp"), + semconv.SessionEnvVar: "myapp-feature", + } + for k, want := range checks { + if got := env[k]; got != want { + t.Errorf("env[%q] = %q, want %q (user env must not shadow codeherd)", k, got, want) + } + } +} + +func TestLaunch_ProfileEnvOmittedWhenEmpty(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + if _, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{}); err != nil { + t.Fatalf("Launch() error = %v", err) + } + env := newSessionEnv(f.Calls) + if _, ok := env[semconv.EnvProfile]; ok { + t.Errorf("CODEHERD_PROFILE must be absent when no profile is active; got %q", env[semconv.EnvProfile]) + } +} + +func TestLaunch_ShellRunsShellCommand(t *testing.T) { + f := &fakeTmux{} + h, dir := sessionHerd(t, f) + mkMyappWorktree(t, dir, "feature") + + handle, err := h.Launch(h.Ref("myapp", "feature"), LaunchOpts{Type: SessionTypeShell}) + if err != nil { + t.Fatalf("Launch() error = %v", err) + } + if handle.Type != SessionTypeShell { + t.Errorf("Type = %q, want shell", handle.Type) + } + if !f.called("new-session", "-s", "myapp-feature~sh") { + t.Errorf("expected new-session on myapp-feature~sh; got %v", f.Calls) + } +} + +func TestResolve_OK(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "running", StartedAt: "2024-01-01T00:00:00Z", + Branch: "feature", Project: "myapp"}, + }} + h, _ := sessionHerd(t, f) + + info, err := h.Resolve(h.Ref("myapp", "feature"), SessionTypeAgent) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if info.TmuxName != "myapp-feature" { + t.Errorf("TmuxName = %q, want myapp-feature", info.TmuxName) + } + if info.ID != "$1" { + t.Errorf("ID = %q, want $1", info.ID) + } + if info.Status != StatusRunning { + t.Errorf("Status = %q, want running", info.Status) + } + if info.StartedAt.IsZero() { + t.Error("StartedAt should be non-zero") + } +} + +func TestResolve_WaitingSession(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$2", Name: "⚡ myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "waiting", Annotation: "need input", + Branch: "feature", Project: "myapp"}, + }} + h, _ := sessionHerd(t, f) + + info, err := h.Resolve(h.Ref("myapp", "feature"), SessionTypeAgent) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if info.TmuxName != "⚡ myapp-feature" { + t.Errorf("TmuxName = %q, want ⚡ myapp-feature", info.TmuxName) + } + if info.ID != "$2" { + t.Errorf("ID = %q, want $2", info.ID) + } +} + +func TestResolve_NotFound(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + _, err := h.Resolve(h.Ref("nonexistent", "branch"), SessionTypeAgent) + if !errors.Is(err, ErrSessionNotFound) { + t.Errorf("expected ErrSessionNotFound, got %v", err) + } +} + +func TestResolve_RunnerError(t *testing.T) { + f := &fakeTmux{RunFn: func(_ ...string) (string, string, int, error) { + return "", "", -1, errors.New("tmux exec failed") + }} + h, _ := sessionHerd(t, f) + + _, err := h.Resolve(h.Ref("myapp", "feature"), SessionTypeAgent) + if err == nil { + t.Fatal("expected error when runner fails") + } +} + +func TestResolve_ShellType(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-main~sh", Canonical: "myapp-main", + Type: "shell", Status: "running", Branch: "main", Project: "myapp"}, + }} + h, _ := sessionHerd(t, f) + + info, err := h.Resolve(h.Ref("myapp", "main"), SessionTypeShell) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if info.Type != SessionTypeShell { + t.Fatalf("Type = %q, want shell", info.Type) + } + // Agent-type Resolve must miss a shell-only session. + if _, err := h.Resolve(h.Ref("myapp", "main"), SessionTypeAgent); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("agent Resolve for shell-only session: want ErrSessionNotFound, got %v", err) + } +} + +func TestSessions_Empty(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions() error = %v", err) + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } +} + +func TestSessions_RunnerError(t *testing.T) { + f := &fakeTmux{RunFn: func(_ ...string) (string, string, int, error) { + return "", "", -1, errors.New("tmux exec failed") + }} + h, _ := sessionHerd(t, f) + + _, err := h.Sessions() + if err == nil { + t.Fatal("expected error when runner fails") + } +} + +func TestSessions_includesAgentAndShell(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "app-main", Canonical: "app-main", + Type: "agent", Status: "running", Branch: "main", Project: "app"}, + {ID: "$2", Name: "app-main~sh", Canonical: "app-main", + Type: "shell", Status: "running", Branch: "main", Project: "app"}, + }} + h, _ := sessionHerd(t, f) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (agent + shell)", len(got)) + } + var sawAgent, sawShell bool + for _, s := range got { + switch s.Type { + case SessionTypeAgent: + sawAgent = true + case SessionTypeShell: + sawShell = true + } + } + if !sawAgent || !sawShell { + t.Fatalf("Sessions missing a type: agent=%v shell=%v", sawAgent, sawShell) + } +} + +func TestSessions_parsesStartedAt(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-main", Canonical: "myapp-main", + Type: "agent", Status: "running", StartedAt: "2024-01-15T10:00:00Z", + Branch: "main", Project: "myapp"}, + }} + h, _ := sessionHerd(t, f) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions() error = %v", err) + } + if len(got) != 1 || got[0].StartedAt.IsZero() { + t.Errorf("StartedAt should be non-zero when timestamp is provided; got %+v", got) + } +} + +// Sessions rebuilds a complete Ref from the tmux options, so a handle from a +// list can be fed straight back into Teardown. +func TestSessions_rebuildsCompleteRef(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d handles, want 1", len(got)) + } + want := Ref{Profile: "work", Project: "myapp", Branch: "feat"} + if got[0].Ref != want { + t.Errorf("Ref = %+v, want %+v", got[0].Ref, want) + } +} + +// Sessions is profile-scoped: another profile's sessions are not ours. +func TestSessions_filtersByActiveProfile(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Profile: "work", Branch: "feat", Project: "myapp"}, + {ID: "$2", Name: "home-myapp-feat", Canonical: "home-myapp-feat", + Type: "agent", Profile: "home", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}} + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + got, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions: %v", err) + } + if len(got) != 1 || got[0].ID != "$1" { + t.Errorf("got %+v, want only the work-profile session", got) + } +} + +func TestStopSessions_OK(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "running", Branch: "feature", Project: "myapp"}, + }} + h, _ := sessionHerd(t, f) + + stopped, err := h.StopSessions(h.Ref("myapp", "feature"), StopOpts{Type: SessionTypeAgent}) + if err != nil { + t.Fatalf("StopSessions() error = %v", err) + } + if len(stopped) != 1 { + t.Fatalf("stopped %d, want 1", len(stopped)) + } + if killed := f.killed(); len(killed) != 1 || killed[0] != "$1" { + t.Errorf("killed = %v, want [$1] (by ID)", killed) + } +} + +func TestStopSessions_KillError(t *testing.T) { + f := &fakeTmux{RunFn: func(args ...string) (string, string, int, error) { + switch args[0] { + case "list-sessions": + row := sessionRow{ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "running", Branch: "feature", Project: "myapp"} + return row.format(), "", 0, nil + case "kill-session": + return "", "kill failed", 1, nil + } + return "", "", 0, nil + }} + h, _ := sessionHerd(t, f) + + if _, err := h.StopSessions(h.Ref("myapp", "feature"), StopOpts{Type: SessionTypeAgent}); err == nil { + t.Fatal("expected error when kill fails") + } +} + +func TestStopSessions_RunnerError(t *testing.T) { + f := &fakeTmux{RunFn: func(_ ...string) (string, string, int, error) { + return "", "", -1, errors.New("tmux exec failed") + }} + h, _ := sessionHerd(t, f) + + if _, err := h.StopSessions(h.Ref("myapp", "feature"), StopOpts{All: true}); err == nil { + t.Fatal("expected error when runner fails") + } +} + +// Under an active profile, sessions are named --. +// session.Service.Stop hardcoded an empty profile via SessionName("", …), so +// it searched for myapp-feat and missed work-myapp-feat entirely. Here the +// profile rides on the Ref and there is no parameter to omit. +func TestStopSessions_underProfile_matchesProfileScopedSession(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + stopped, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{Type: SessionTypeAgent}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 1 { + t.Fatalf("stopped %d sessions, want 1", len(stopped)) + } + if killed := f.killed(); len(killed) != 1 || killed[0] != "$1" { + t.Errorf("killed = %v, want [$1] — the session was addressed by name, not ID", killed) + } +} + +// StopOpts.All is what Teardown uses: both types die, addressed by ID. +func TestStopSessions_all_stopsBothTypesByID(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Profile: "work", Branch: "feat", Project: "myapp"}, + {ID: "$2", Name: "work-myapp-feat~sh", Canonical: "work-myapp-feat", + Type: "shell", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + stopped, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{All: true}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 2 { + t.Fatalf("stopped %d sessions, want 2", len(stopped)) + } + killed := f.killed() + sort.Strings(killed) + if len(killed) != 2 || killed[0] != "$1" || killed[1] != "$2" { + t.Errorf("killed = %v, want [$1 $2]", killed) + } +} + +// Stopping a session that isn't running is not an error: Teardown calls this +// unconditionally, and a worktree with no sessions is the common case. +func TestStopSessions_noneRunning_isNotAnError(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + stopped, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{All: true}) + if err != nil { + t.Fatalf("StopSessions: %v", err) + } + if len(stopped) != 0 { + t.Errorf("stopped = %v, want empty", stopped) + } +} + +func TestSetStatus_Running(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "⚡ myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "waiting"}, + }} + h, _ := sessionHerd(t, f) + + if err := h.SetStatus("myapp-feature", StatusRunning, ""); err != nil { + t.Fatalf("SetStatus() error = %v", err) + } + // Running + prefixed name → rename drops the ⚡ prefix. + if !f.called("rename-session", "⚡ myapp-feature", "myapp-feature") { + t.Errorf("expected rename dropping prefix; calls=%v", f.Calls) + } +} + +func TestSetStatus_Waiting(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "running"}, + }} + h, _ := sessionHerd(t, f) + + if err := h.SetStatus("myapp-feature", StatusWaiting, "Claude needs input"); err != nil { + t.Fatalf("SetStatus() error = %v", err) + } + if !f.called("rename-session", "myapp-feature", semconv.StatusPrefix+"myapp-feature") { + t.Errorf("expected rename adding prefix; calls=%v", f.Calls) + } +} + +func TestSetStatus_EmptyName(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + if err := h.SetStatus("", StatusRunning, ""); err != nil { + t.Fatalf("SetStatus() on empty name error = %v", err) + } + if len(f.Calls) != 0 { + t.Errorf("expected 0 calls, got %d", len(f.Calls)) + } +} + +func TestSetStatus_InvalidStatus(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + if err := h.SetStatus("myapp-feature", Status("invalid"), ""); err != nil { + t.Fatalf("SetStatus() should suppress errors: %v", err) + } + if len(f.Calls) != 0 { + t.Errorf("expected 0 calls for invalid status, got %d", len(f.Calls)) + } +} + +func TestSetStatus_SuppressesError(t *testing.T) { + f := &fakeTmux{RunFn: func(_ ...string) (string, string, int, error) { + return "", "", -1, errors.New("tmux failed") + }} + h, _ := sessionHerd(t, f) + + if err := h.SetStatus("any-session", StatusRunning, ""); err != nil { + t.Fatalf("SetStatus() should suppress errors: %v", err) + } +} + +func TestSetStatus_SessionNotFound(t *testing.T) { + f := &fakeTmux{} + h, _ := sessionHerd(t, f) + + if err := h.SetStatus("myapp-feature", StatusRunning, ""); err != nil { + t.Fatalf("SetStatus() should suppress not-found: %v", err) + } + // Only list-sessions was called; no set-option/rename. + if len(f.Calls) != 1 { + t.Errorf("expected 1 call (list only), got %d: %v", len(f.Calls), f.Calls) + } +} + +// A session created before @codeherd_project existed has a correct stored +// canonical name but an empty Project. It must still be found and killed — +// this is the exact orphan the collapse reintroduced. +func TestStopSessions_preUpgradeSession_matchedByStoredCanonical(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: ""}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: t.TempDir()}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + if _, err := h.Resolve(h.Ref("myapp", "feat"), SessionTypeAgent); err != nil { + t.Fatalf("Resolve found nothing for a pre-upgrade session: %v", err) + } + if _, err := h.StopSessions(h.Ref("myapp", "feat"), StopOpts{}); err != nil { + t.Fatalf("StopSessions: %v", err) + } + if got := f.killed(); len(got) != 1 || got[0] != "$1" { + t.Errorf("killed = %v, want [$1] — the pre-upgrade session was not killed", got) + } +} + +func TestResolveProject(t *testing.T) { + cfg := &config.Config{Projects: map[string]config.ProjectConfig{ + "myapp": {}, "other": {}, + }} + tests := []struct { + name string + profile, branch string + canonical string + wantProj string + wantOK bool + }{ + {"under profile", "work", "feat", "work-myapp-feat", "myapp", true}, + {"no profile", "", "feat", "myapp-feat", "myapp", true}, + {"flattened slash branch", "work", "feat/login", "work-myapp-feat-login", "myapp", true}, + {"no configured match", "work", "feat", "work-nope-feat", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := resolveProject(cfg, tt.profile, tt.branch, tt.canonical) + if got != tt.wantProj || ok != tt.wantOK { + t.Errorf("resolveProject = (%q, %v), want (%q, %v)", got, ok, tt.wantProj, tt.wantOK) + } + }) + } +} + +// A pre-upgrade session's project is recovered for display and re-stamped on +// the live session, so it heals to first-class on first observation. +func TestSessions_preUpgradeSession_recoversAndHealsProject(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: ""}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: t.TempDir()}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + sessions, err := h.Sessions() + if err != nil { + t.Fatalf("Sessions: %v", err) + } + if len(sessions) != 1 || sessions[0].Ref.Project != "myapp" { + t.Fatalf("Ref.Project = %q, want %q", sessions[0].Ref.Project, "myapp") + } + if !f.called("set-option", "@codeherd_project", "myapp") { + t.Errorf("project was not re-stamped; calls=%v", f.Calls) + } +} + +// A session that already carries @codeherd_project is never re-stamped. +func TestSessions_stampedSession_isNotHealed(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: t.TempDir()}, + Projects: map[string]config.ProjectConfig{"myapp": {Repo: "git@github.com:user/myapp.git"}}, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + if _, err := h.Sessions(); err != nil { + t.Fatalf("Sessions: %v", err) + } + if f.called("set-option", "@codeherd_project") { + t.Errorf("an already-stamped session was healed again; calls=%v", f.Calls) + } +} + +func TestSessionExistsError(t *testing.T) { + err := &SessionExistsError{ + Ref: Ref{Project: "myapp", Branch: "feature"}, + Type: SessionTypeAgent, + } + want := "session already exists: myapp/feature (agent)" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } + if !errors.Is(err, ErrSessionExists) { + t.Error("errors.Is(err, ErrSessionExists) = false, want true") + } +} diff --git a/internal/herd/workspace.go b/internal/herd/workspace.go new file mode 100644 index 0000000..cd6b1f3 --- /dev/null +++ b/internal/herd/workspace.go @@ -0,0 +1,375 @@ +package herd + +import ( + "errors" + "fmt" + "os" + + "github.com/xico42/codeherd/internal/filecopy" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/herdtemplate" + "github.com/xico42/codeherd/internal/semconv" +) + +// Workspace is a worktree together with its sessions — the domain object the +// old split could not express. +type Workspace struct { + // Ref is identity. Feed it back into any operation. It survives a + // diverged HEAD, a profile switch, and a rename. + Ref Ref + Path string + IsMain bool // true for the main clone dir + + // DisplayBranch is what a front end should render: the branch HEAD is + // actually on. It is NOT identity and must never be fed back in — that + // round-trip is what orphaned an agent against a deleted worktree. + DisplayBranch string + + // HeadHint is "detached", "on ", or "" when HEAD agrees with Ref. + HeadHint string + + // Agent and Shell are nil when that session type is not running. + Agent *Handle + Shell *Handle +} + +// EnsureOpts configures workspace creation. The zero value creates the +// worktree from the project's default branch and provisions nothing. +type EnsureOpts struct { + AutoClone bool // clone the project first if it is not cloned + Provision bool // run file copy + .herd templates after creating + StartPoint string // --from: base the new branch on this ref + Track string // --track: "[/]"; derives the local name when Ref.Branch is "" +} + +// TeardownOpts configures workspace deletion. +type TeardownOpts struct { + Force bool // kill running sessions instead of refusing +} + +// EnsureWorkspace makes the workspace for ref exist: clone if asked, create +// the worktree if missing, provision if asked. It is idempotent on the clone +// but not on the worktree — an existing worktree returns ErrWorktreeExists. +// +// The returned Workspace.Ref is authoritative: with Track, the local branch +// is derived from the remote ref and may differ from the ref passed in. +func (h *Herd) EnsureWorkspace(ref Ref, opts EnsureOpts) (Workspace, error) { + if opts.StartPoint != "" && opts.Track != "" { + return Workspace{}, errors.New("cannot combine a start point with a tracking ref") + } + + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return Workspace{}, err + } + if opts.AutoClone { + // Already cloned is the normal case, not a failure. + if err := h.Clone(ref.Project); err != nil && !errors.Is(err, ErrAlreadyCloned) { + return Workspace{}, err + } + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return Workspace{}, fmt.Errorf("%w: %s", ErrNotCloned, ref.Project) + } + + // A tracking ref decides the local branch name, so resolve it before the + // ref is used for anything path-shaped. + remoteRef := "" + if opts.Track != "" { + remotes, _ := h.git.Remotes(cloneDir) + remote, remoteBranch, _ := git.ParseRef(remotes, opts.Track) + if ref.Branch == "" { + ref.Branch = remoteBranch + } + remoteRef = remote + "/" + remoteBranch + if has, _ := h.git.HasLocalBranch(cloneDir, ref.Branch); has { + return Workspace{}, fmt.Errorf("%w: %s", ErrLocalBranchExists, ref.Branch) + } + if err := h.git.Fetch(cloneDir, remote, remoteBranch); err != nil { + return Workspace{}, fmt.Errorf("fetching %s: %w", remoteRef, err) + } + } + + wtPath, err := h.worktreePath(ref) + if err != nil { + return Workspace{}, err + } + if _, err := os.Stat(wtPath); err == nil { + return Workspace{}, fmt.Errorf("%w: %s/%s", ErrWorktreeExists, ref.Project, ref.Branch) + } + + p := h.cfg.Projects[ref.Project] + hook := h.hookFor(ref.Project) + attrs := map[string]string{ + semconv.HookAttrProject: ref.Project, + semconv.HookAttrBranch: ref.Branch, + semconv.HookAttrRepo: p.Repo, + semconv.HookAttrCloneDir: cloneDir, + semconv.HookAttrWorktreePath: wtPath, + } + if err := hook.Trigger(semconv.HookPreWorktree, attrs, wtPath); err != nil { + return Workspace{}, fmt.Errorf("pre-worktree hook: %w", err) + } + + root, err := h.worktreesRoot(ref.Project) + if err != nil { + return Workspace{}, err + } + if err := os.MkdirAll(root, 0o755); err != nil { + return Workspace{}, fmt.Errorf("creating worktrees dir: %w", err) + } + + if err := h.addWorktree(ref, cloneDir, wtPath, remoteRef, opts); err != nil { + return Workspace{}, err + } + + if err := hook.Trigger(semconv.HookPostWorktree, attrs, wtPath); err != nil { + return Workspace{}, fmt.Errorf("post-worktree hook: %w", err) + } + + if opts.Provision { + if err := h.Provision(ref); err != nil { + return Workspace{}, err + } + } + + return Workspace{ + Ref: ref, + Path: wtPath, + IsMain: wtPath == cloneDir, + DisplayBranch: ref.Branch, + }, nil +} + +// addWorktree runs the git call that actually creates the worktree. The three +// shapes were three near-identical 50-line methods; only this switch differed. +func (h *Herd) addWorktree(ref Ref, cloneDir, wtPath, remoteRef string, opts EnsureOpts) error { + switch { + case opts.Track != "": + if err := h.git.AddTracking(cloneDir, wtPath, ref.Branch, remoteRef); err != nil { + return fmt.Errorf("creating tracking worktree for %s: %w", remoteRef, err) + } + return nil + + case opts.StartPoint != "": + startPoint := h.freshenStartPoint(cloneDir, opts.StartPoint) + if err := h.git.AddNewBranchFrom(cloneDir, wtPath, ref.Branch, startPoint); err != nil { + return fmt.Errorf("creating worktree from %s: %w", startPoint, err) + } + return nil + + default: + // Try checking out an existing branch; fall back to branching from + // the project's default. + addErr := h.git.Add(cloneDir, wtPath, ref.Branch) + if addErr == nil { + return nil + } + src := h.cfg.Projects[ref.Project].DefaultBranch + if src == "" { + src = "main" + } + startPoint := h.freshenStartPoint(cloneDir, src) + if err := h.git.AddNewBranchFrom(cloneDir, wtPath, ref.Branch, startPoint); err != nil { + return fmt.Errorf("failed to create worktree (add: %v; add -b from %s: %w)", addErr, startPoint, err) + } + return nil + } +} + +// freshenStartPoint fetches updates for the source ref and returns the start +// point a new branch should be based on. It prefers a fast-forwarded local +// branch (to preserve un-pushed commits), falling back to the remote-tracking +// ref, or the raw ref when the source is not on a remote (tags, SHAs, +// local-only branches). All git failures here are best-effort. +func (h *Herd) freshenStartPoint(cloneDir, src string) string { + remotes, _ := h.git.Remotes(cloneDir) + remote, branch, explicit := git.ParseRef(remotes, src) + if explicit { + _ = h.git.Fetch(cloneDir, remote, branch) + return src + } + if err := h.git.Fetch(cloneDir, "origin", src); err != nil { + return src + } + if has, _ := h.git.HasLocalBranch(cloneDir, src); has { + _ = h.git.FastForward(cloneDir, "origin", src) + return src + } + return "origin/" + src +} + +// Provision runs file copy and .herd template processing for a workspace. +// +// The template context is built from ref in one place, which is what kills +// the divergence where `ch create session` rendered a profile-qualified +// SessionName into a .herd file while `ch create worktree`, `ch template`, +// and the TUI rendered a profile-blind one — for the same worktree. +func (h *Herd) Provision(ref Ref) error { + wtPath, err := h.worktreePath(ref) + if err != nil { + return err + } + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return err + } + + p := h.cfg.Projects[ref.Project] + hook := h.hookFor(ref.Project) + attrs := map[string]string{ + semconv.HookAttrProject: ref.Project, + semconv.HookAttrBranch: ref.Branch, + semconv.HookAttrWorktreePath: wtPath, + } + + if len(p.Files) > 0 { + if err := filecopy.New(hook).Copy(p.Files, cloneDir, wtPath, attrs); err != nil { + return fmt.Errorf("copying files: %w", err) + } + } + + if _, err := herdtemplate.New(hook).Process(herdtemplate.ProcessContext{ + Project: ref.Project, + Branch: ref.Branch, + WorktreePath: wtPath, + SessionName: ref.CanonicalName(), + }, attrs); err != nil { + return fmt.Errorf("processing templates: %w", err) + } + return nil +} + +// List returns every workspace for a project, or for all projects when +// project is "". Projects that are not cloned, and projects whose git calls +// fail, are skipped rather than failing the whole listing. +// +// This is the one place worktrees and sessions are joined, and the join is on +// the Ref — which carries the profile. The old split computed identity in +// worktree.Service.List, threw it away into a display string, and made the +// TUI recompute it. +func (h *Herd) List(project string) ([]Workspace, error) { + names, err := h.projectNames(project) + if err != nil { + return nil, err + } + sessions, err := h.Sessions() + if err != nil { + return nil, err + } + byName := make(map[string][]Handle, len(sessions)) + for _, hd := range sessions { + key := hd.Canonical + byName[key] = append(byName[key], hd) + } + + var out []Workspace + for _, name := range names { + cloneDir, err := h.cloneDir(name) + if err != nil { + continue + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + continue + } + infos, err := h.git.List(cloneDir) + if err != nil { + continue + } + defaultBranch := h.cfg.Projects[name].DefaultBranch + for _, wt := range infos { + ws := h.workspaceFrom(name, cloneDir, defaultBranch, wt) + for i := range byName[ws.Ref.CanonicalName()] { + hd := byName[ws.Ref.CanonicalName()][i] + switch hd.Type { + case SessionTypeAgent: + ws.Agent = &hd + case SessionTypeShell: + ws.Shell = &hd + } + } + out = append(out, ws) + } + } + return out, nil +} + +// workspaceFrom derives identity and display from one git worktree entry. +func (h *Herd) workspaceFrom(project, cloneDir, defaultBranch string, wt git.WorktreeInfo) Workspace { + identity := semconv.WorktreeIdentityBranch(wt.Path, cloneDir, defaultBranch, wt.Branch) + ws := Workspace{ + Ref: h.Ref(project, identity), + Path: wt.Path, + IsMain: wt.Path == cloneDir, + DisplayBranch: wt.Branch, + } + switch { + case wt.Detached: + ws.HeadHint = "detached" + case wt.Branch != "" && semconv.FlattenBranch(wt.Branch) != semconv.FlattenBranch(identity): + ws.HeadHint = "on " + wt.Branch + } + return ws +} + +// Teardown stops a workspace's sessions and deletes its worktree. +// +// The order is not incidental. The TUI killed sessions by ID and then called +// worktree.Delete, which ran a second, profile-blind kill loop that either +// missed or no-opped — and force-deleted the worktree either way, orphaning +// the agent process. One loop, keyed on a Ref that carries the profile. +func (h *Herd) Teardown(ref Ref, opts TeardownOpts) error { + wtPath, err := h.worktreePath(ref) + if err != nil { + return err + } + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + return fmt.Errorf("%w: %s/%s", ErrWorktreeNotFound, ref.Project, ref.Branch) + } + cloneDir, err := h.cloneDir(ref.Project) + if err != nil { + return err + } + + if !opts.Force { + running, err := h.handles() + if err != nil { + return err + } + canonical := ref.CanonicalName() + for _, hd := range running { + if hd.Canonical == canonical { + return fmt.Errorf("%w: %s (%s)", ErrSessionRunning, canonical, hd.Type) + } + } + } + + if _, err := h.StopSessions(ref, StopOpts{All: true}); err != nil { + return err + } + if err := h.git.Remove(cloneDir, wtPath); err != nil { + return fmt.Errorf("removing worktree: %w", err) + } + return nil +} + +// RemoteBranches returns a project's remote-tracking branches. When fetch is +// true it refreshes all remotes first (best-effort) so the list reflects +// current remote state; completion passes false to stay fast. +func (h *Herd) RemoteBranches(project string, fetch bool) ([]RemoteBranch, error) { + cloneDir, err := h.cloneDir(project) + if err != nil { + return nil, err + } + if _, err := os.Stat(cloneDir); os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotCloned, project) + } + if fetch { + _ = h.git.FetchAll(cloneDir) + } + branches, err := h.git.ListRemoteBranches(cloneDir) + if err != nil { + return nil, fmt.Errorf("listing remote branches: %w", err) + } + return branches, nil +} diff --git a/internal/herd/workspace_test.go b/internal/herd/workspace_test.go new file mode 100644 index 0000000..1bb204d --- /dev/null +++ b/internal/herd/workspace_test.go @@ -0,0 +1,908 @@ +package herd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/xico42/codeherd/internal/config" + "github.com/xico42/codeherd/internal/git" + "github.com/xico42/codeherd/internal/semconv" +) + +// workspaceHerd builds a Herd wired to the given fakes, with one configured +// project "myapp". Returns the Herd and the projects_dir so tests can +// materialize the worktree paths derived from a Ref. +func workspaceHerd(t *testing.T, g *fakeGit, f *fakeTmux) (*Herd, string) { + t.Helper() + tmpDir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: tmpDir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + return New(cfg, nil, Deps{Tmux: f, Git: g}), tmpDir +} + +// cloneDirPath returns the expected clone path for "myapp" in tmpDir. +func cloneDirPath(tmpDir string) string { + return filepath.Join(tmpDir, "github.com", "user", "myapp") +} + +// ── freshenStartPoint ───────────────────────────────────────────────────────── + +func TestFreshenStartPoint_localBranchPreferred(t *testing.T) { + g := &fakeGit{HasLocalBranchFn: func(_, _ string) (bool, error) { return true, nil }} + h, _ := workspaceHerd(t, g, &fakeTmux{}) + got := h.freshenStartPoint("/clone", "main") + if got != "main" { + t.Errorf("start point = %q, want %q", got, "main") + } + if !g.called("Fetch", "origin", "main") { + t.Errorf("expected Fetch origin main; calls=%v", g.Calls) + } + if !g.called("FastForward", "origin", "main") { + t.Errorf("expected FastForward origin main; calls=%v", g.Calls) + } +} + +func TestFreshenStartPoint_noLocalBranchUsesRemoteTracking(t *testing.T) { + g := &fakeGit{HasLocalBranchFn: func(_, _ string) (bool, error) { return false, nil }} + h, _ := workspaceHerd(t, g, &fakeTmux{}) + got := h.freshenStartPoint("/clone", "feat-x") + if got != "origin/feat-x" { + t.Errorf("start point = %q, want %q", got, "origin/feat-x") + } + if g.called("FastForward") { + t.Errorf("did not expect fast-forward; calls=%v", g.Calls) + } +} + +func TestFreshenStartPoint_fetchFailsFallsBackToRaw(t *testing.T) { + g := &fakeGit{FetchFn: func(_, _, _ string) error { return fmt.Errorf("no such branch") }} + h, _ := workspaceHerd(t, g, &fakeTmux{}) + got := h.freshenStartPoint("/clone", "v1.2.3") + if got != "v1.2.3" { + t.Errorf("start point = %q, want %q", got, "v1.2.3") + } +} + +func TestFreshenStartPoint_explicitRemoteRef(t *testing.T) { + g := &fakeGit{RemotesFn: func(_ string) ([]string, error) { return []string{"origin", "upstream"}, nil }} + h, _ := workspaceHerd(t, g, &fakeTmux{}) + got := h.freshenStartPoint("/clone", "upstream/feat-x") + if got != "upstream/feat-x" { + t.Errorf("start point = %q, want %q", got, "upstream/feat-x") + } + if !g.called("Fetch", "upstream", "feat-x") { + t.Errorf("expected Fetch upstream feat-x; calls=%v", g.Calls) + } +} + +// ── EnsureWorkspace (default / --from) ──────────────────────────────────────── + +func TestEnsureWorkspace_notCloned(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{}) + if !errors.Is(err, ErrNotCloned) { + t.Errorf("expected ErrNotCloned, got %v", err) + } +} + +func TestEnsureWorkspace_worktreeExists(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{}) + if !errors.Is(err, ErrWorktreeExists) { + t.Errorf("expected ErrWorktreeExists, got %v", err) + } +} + +func TestEnsureWorkspace_success(t *testing.T) { + g := &fakeGit{} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + ws, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("Add", "feature") { + t.Errorf("expected git.Add to be called; calls=%v", g.Calls) + } + want := cloneDirPath(tmpDir) + "__worktrees/feature" + if ws.Path != want { + t.Errorf("path = %q, want %q", ws.Path, want) + } + if ws.Ref.Branch != "feature" { + t.Errorf("ref branch = %q, want feature", ws.Ref.Branch) + } +} + +func TestEnsureWorkspace_branchNotFound_createsFromFreshDefault(t *testing.T) { + g := &fakeGit{ + AddFn: func(_, _, _ string) error { return fmt.Errorf("invalid reference") }, + HasLocalBranchFn: func(_, _ string) (bool, error) { return false, nil }, + } + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + ws, err := h.EnsureWorkspace(h.Ref("myapp", "new-feature"), EnsureOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("AddNewBranchFrom", "new-feature", "origin/main") { + t.Errorf("expected AddNewBranchFrom from origin/main; calls=%v", g.Calls) + } + if ws.Ref.Branch != "new-feature" { + t.Errorf("branch = %q, want new-feature", ws.Ref.Branch) + } +} + +func TestEnsureWorkspace_withFromBranch(t *testing.T) { + g := &fakeGit{HasLocalBranchFn: func(_, _ string) (bool, error) { return false, nil }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + ws, err := h.EnsureWorkspace(h.Ref("myapp", "my-feature"), EnsureOpts{StartPoint: "feature-auth"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("AddNewBranchFrom", "my-feature", "origin/feature-auth") { + t.Errorf("expected AddNewBranchFrom from origin/feature-auth; calls=%v", g.Calls) + } + if !g.called("Fetch", "origin", "feature-auth") { + t.Errorf("expected Fetch origin feature-auth; calls=%v", g.Calls) + } + want := cloneDirPath(tmpDir) + "__worktrees/my-feature" + if ws.Path != want { + t.Errorf("path = %q, want %q", ws.Path, want) + } +} + +func TestEnsureWorkspace_fromAndTrackMutuallyExclusive(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "x"), EnsureOpts{StartPoint: "main", Track: "origin/main"}) + if err == nil { + t.Fatal("expected error when both StartPoint and Track are set") + } +} + +func TestEnsureWorkspace_fromBranch_notCloned(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{StartPoint: "main"}) + if !errors.Is(err, ErrNotCloned) { + t.Errorf("expected ErrNotCloned, got %v", err) + } +} + +func TestEnsureWorkspace_fromBranch_worktreeExists(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{StartPoint: "main"}) + if !errors.Is(err, ErrWorktreeExists) { + t.Errorf("expected ErrWorktreeExists, got %v", err) + } +} + +func TestEnsureWorkspace_fromBranch_gitError(t *testing.T) { + g := &fakeGit{AddNewBranchFromFn: func(_, _, _, _ string) error { return fmt.Errorf("invalid start point") }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{StartPoint: "nonexistent"}) + if err == nil { + t.Fatal("expected error when AddNewBranchFrom fails") + } +} + +func TestEnsureWorkspace_unknownProject(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + _, err := h.EnsureWorkspace(h.Ref("unknown", "feature"), EnsureOpts{}) + if err == nil { + t.Fatal("expected error for unconfigured project") + } +} + +func TestEnsureWorkspace_bothAddsFail(t *testing.T) { + g := &fakeGit{ + AddFn: func(_, _, _ string) error { return fmt.Errorf("invalid reference") }, + AddNewBranchFromFn: func(_, _, _, _ string) error { return fmt.Errorf("already exists") }, + } + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "new-feature"), EnsureOpts{}) + if err == nil { + t.Fatal("expected error when both Add and AddNewBranchFrom fail") + } +} + +func TestEnsureWorkspace_branchFlattened(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + ws, err := h.EnsureWorkspace(h.Ref("myapp", "feature/login"), EnsureOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if filepath.Base(ws.Path) != "feature-login" { + t.Errorf("expected flattened path, got %q", ws.Path) + } +} + +func TestEnsureWorkspace_invalidRepo(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: tmpDir}, + Projects: map[string]config.ProjectConfig{ + "badrepo": {Repo: "not-a-valid-repo-url"}, + }, + } + h := New(cfg, nil, Deps{Tmux: &fakeTmux{}, Git: &fakeGit{}}) + _, err := h.EnsureWorkspace(h.Ref("badrepo", "feature"), EnsureOpts{}) + if err == nil { + t.Fatal("expected error for invalid repo URL") + } +} + +// ── EnsureWorkspace (--track) ───────────────────────────────────────────────── + +func TestEnsureWorkspace_tracking_success(t *testing.T) { + g := &fakeGit{} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + ws, err := h.EnsureWorkspace(h.Ref("myapp", ""), EnsureOpts{Track: "feat-x"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("Fetch", "origin", "feat-x") { + t.Errorf("expected Fetch origin feat-x; calls=%v", g.Calls) + } + if !g.called("AddTracking", "feat-x", "origin/feat-x") { + t.Errorf("expected AddTracking feat-x origin/feat-x; calls=%v", g.Calls) + } + // The result's Ref is authoritative — the local branch was derived. + if ws.Ref.Branch != "feat-x" { + t.Errorf("ref branch = %q, want feat-x", ws.Ref.Branch) + } + want := cloneDirPath(tmpDir) + "__worktrees/feat-x" + if ws.Path != want { + t.Errorf("path = %q, want %q", ws.Path, want) + } +} + +func TestEnsureWorkspace_tracking_overrideName(t *testing.T) { + g := &fakeGit{RemotesFn: func(_ string) ([]string, error) { return []string{"origin", "upstream"}, nil }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + ws, err := h.EnsureWorkspace(h.Ref("myapp", "review"), EnsureOpts{Track: "upstream/feat-x"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("AddTracking", "review", "upstream/feat-x") { + t.Errorf("expected AddTracking review upstream/feat-x; calls=%v", g.Calls) + } + if ws.Ref.Branch != "review" { + t.Errorf("ref branch = %q, want review", ws.Ref.Branch) + } +} + +func TestEnsureWorkspace_tracking_localBranchExists(t *testing.T) { + g := &fakeGit{HasLocalBranchFn: func(_, _ string) (bool, error) { return true, nil }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", ""), EnsureOpts{Track: "feat-x"}) + if !errors.Is(err, ErrLocalBranchExists) { + t.Errorf("expected ErrLocalBranchExists, got %v", err) + } + if g.called("AddTracking") { + t.Error("AddTracking should not be called when the branch already exists") + } +} + +func TestEnsureWorkspace_tracking_fetchFails(t *testing.T) { + g := &fakeGit{FetchFn: func(_, _, _ string) error { return fmt.Errorf("no such ref") }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", ""), EnsureOpts{Track: "feat-x"}) + if err == nil { + t.Fatal("expected error when fetch fails") + } + if g.called("AddTracking") { + t.Error("AddTracking should not run after a failed fetch") + } +} + +func TestEnsureWorkspace_tracking_notCloned(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + _, err := h.EnsureWorkspace(h.Ref("myapp", ""), EnsureOpts{Track: "feat-x"}) + if !errors.Is(err, ErrNotCloned) { + t.Errorf("expected ErrNotCloned, got %v", err) + } +} + +// ── Hooks ───────────────────────────────────────────────────────────────────── + +func TestEnsureWorkspace_TriggersHooks(t *testing.T) { + g := &fakeGit{} + hook := &mockHook{} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + withHook(h, hook) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + if _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{}); err != nil { + t.Fatalf("EnsureWorkspace error = %v", err) + } + if len(hook.calls) < 2 { + t.Fatalf("expected at least 2 hook calls, got %d", len(hook.calls)) + } + if hook.calls[0].name != semconv.HookPreWorktree { + t.Errorf("first hook = %q, want %q", hook.calls[0].name, semconv.HookPreWorktree) + } + if hook.calls[1].name != semconv.HookPostWorktree { + t.Errorf("second hook = %q, want %q", hook.calls[1].name, semconv.HookPostWorktree) + } +} + +func TestEnsureWorkspace_preHookFailure(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + withHook(h, &mockHook{failOn: semconv.HookPreWorktree}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{}) + if err == nil || !contains(err.Error(), "pre-worktree hook") { + t.Errorf("expected pre-worktree hook error, got %v", err) + } +} + +func TestEnsureWorkspace_postHookFailure(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + withHook(h, &mockHook{failOn: semconv.HookPostWorktree}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{}) + if err == nil || !contains(err.Error(), "post-worktree hook") { + t.Errorf("expected post-worktree hook error, got %v", err) + } +} + +func TestEnsureWorkspace_fromBranch_preHookFailure(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + withHook(h, &mockHook{failOn: semconv.HookPreWorktree}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{StartPoint: "main"}) + if err == nil || !contains(err.Error(), "pre-worktree hook") { + t.Errorf("expected pre-worktree hook error, got %v", err) + } +} + +func TestEnsureWorkspace_fromBranch_postHookFailure(t *testing.T) { + h, tmpDir := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + withHook(h, &mockHook{failOn: semconv.HookPostWorktree}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + _, err := h.EnsureWorkspace(h.Ref("myapp", "feature"), EnsureOpts{StartPoint: "main"}) + if err == nil || !contains(err.Error(), "post-worktree hook") { + t.Errorf("expected post-worktree hook error, got %v", err) + } +} + +// ── List ────────────────────────────────────────────────────────────────────── + +func TestList_allProjects(t *testing.T) { + tmpDir := t.TempDir() + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{ + {Path: cloneDirPath(tmpDir), Branch: "main"}, + {Path: cloneDirPath(tmpDir) + "__worktrees/feature", Branch: "feature"}, + }, nil + }} + h, _ := workspaceHerdIn(t, tmpDir, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + spaces, err := h.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spaces) != 2 { + t.Fatalf("expected 2 workspaces, got %d", len(spaces)) + } + if spaces[0].Ref.Project != "myapp" { + t.Errorf("expected project myapp, got %q", spaces[0].Ref.Project) + } +} + +func TestList_withRunningSession(t *testing.T) { + tmpDir := t.TempDir() + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{{Path: cloneDirPath(tmpDir) + "__worktrees/feature", Branch: "feature"}}, nil + }} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", + Type: "agent", Status: "running", Branch: "feature", Project: "myapp"}, + }} + h, _ := workspaceHerdIn(t, tmpDir, g, f) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + spaces, err := h.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spaces) == 0 { + t.Fatal("expected workspaces") + } + if spaces[0].Agent == nil { + t.Errorf("expected running agent to be joined to its workspace") + } +} + +func TestList_cloneDirDetachedUsesDefaultBranch(t *testing.T) { + tmpDir := t.TempDir() + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{{Path: cloneDirPath(tmpDir), Branch: "", Detached: true}}, nil + }} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-main", Canonical: "myapp-main", + Type: "agent", Status: "running", Branch: "main", Project: "myapp"}, + }} + h, _ := workspaceHerdIn(t, tmpDir, g, f) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + spaces, err := h.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(spaces)) + } + if spaces[0].Agent == nil { + t.Errorf("expected session correlated via DefaultBranch identity") + } + if spaces[0].HeadHint != "detached" { + t.Errorf("HeadHint = %q, want detached", spaces[0].HeadHint) + } +} + +func TestList_skipUncloned(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + // cloneDir does not exist — project should be skipped. + spaces, err := h.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spaces) != 0 { + t.Errorf("expected no workspaces for uncloned project, got %d", len(spaces)) + } +} + +func TestList_singleProject_notConfigured(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + _, err := h.List("nonexistent") + if err == nil { + t.Fatal("expected error for unconfigured project") + } +} + +func TestList_gitListError(t *testing.T) { + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { return nil, fmt.Errorf("git error") }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + spaces, err := h.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spaces) != 0 { + t.Errorf("expected 0 workspaces when git.List fails, got %d", len(spaces)) + } +} + +func TestList_invalidRepoSkipped(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: tmpDir}, + Projects: map[string]config.ProjectConfig{ + "badrepo": {Repo: "not-a-valid-url"}, + }, + } + h := New(cfg, nil, Deps{Tmux: &fakeTmux{}, Git: &fakeGit{}}) + spaces, err := h.List("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spaces) != 0 { + t.Errorf("expected 0 workspaces for invalid repo, got %d", len(spaces)) + } +} + +// ── Teardown ────────────────────────────────────────────────────────────────── + +func TestTeardown_notFound(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + err := h.Teardown(h.Ref("myapp", "feature"), TeardownOpts{}) + if !errors.Is(err, ErrWorktreeNotFound) { + t.Errorf("expected ErrWorktreeNotFound, got %v", err) + } +} + +func TestTeardown_success(t *testing.T) { + g := &fakeGit{} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + if err := h.Teardown(h.Ref("myapp", "feature"), TeardownOpts{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("Remove") { + t.Errorf("expected git.Remove to be called; calls=%v", g.Calls) + } +} + +func TestTeardown_unknownProject(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + err := h.Teardown(h.Ref("unknown", "feature"), TeardownOpts{}) + if err == nil { + t.Fatal("expected error for unknown project") + } +} + +func TestTeardown_listSessionsError(t *testing.T) { + f := &fakeTmux{RunFn: func(args ...string) (string, string, int, error) { + if args[0] == "list-sessions" { + return "", "boom", 2, fmt.Errorf("tmux not running") + } + return "", "", 0, nil + }} + h, tmpDir := workspaceHerd(t, &fakeGit{}, f) + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + err := h.Teardown(h.Ref("myapp", "feature"), TeardownOpts{}) + if err == nil { + t.Fatal("expected error when listing tmux sessions fails") + } +} + +func TestTeardown_gitRemoveError(t *testing.T) { + g := &fakeGit{RemoveFn: func(_, _ string) error { return fmt.Errorf("git worktree remove failed") }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + err := h.Teardown(h.Ref("myapp", "feature"), TeardownOpts{}) + if err == nil || !contains(err.Error(), "removing worktree") { + t.Errorf("expected 'removing worktree' error, got %v", err) + } +} + +func TestTeardown_forceKillSessionError(t *testing.T) { + f := &fakeTmux{ + Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", Type: "agent", Branch: "feature", Project: "myapp"}, + }, + RunFn: func(args ...string) (string, string, int, error) { + switch args[0] { + case "list-sessions": + return strings.Join([]string{"$1\tmyapp-feature\tmyapp-feature\tagent\t\t\t\t\tfeature\tmyapp"}, "\n"), "", 0, nil + case "kill-session": + return "", "kill failed", 1, fmt.Errorf("kill failed") + } + return "", "", 0, nil + }, + } + h, tmpDir := workspaceHerd(t, &fakeGit{}, f) + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + err := h.Teardown(h.Ref("myapp", "feature"), TeardownOpts{Force: true}) + if err == nil || !contains(err.Error(), "killing session") { + t.Errorf("expected 'killing session' error, got %v", err) + } +} + +func TestTeardown_Force_KillsBothSessionTypes(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feature", Canonical: "myapp-feature", Type: "agent", Branch: "feature", Project: "myapp"}, + {ID: "$2", Name: "myapp-feature~sh", Canonical: "myapp-feature", Type: "shell", Branch: "feature", Project: "myapp"}, + }} + g := &fakeGit{} + h, tmpDir := workspaceHerd(t, g, f) + if err := os.MkdirAll(cloneDirPath(tmpDir)+"__worktrees/feature", 0o755); err != nil { + t.Fatal(err) + } + if err := h.Teardown(h.Ref("myapp", "feature"), TeardownOpts{Force: true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + killed := f.killed() + sort.Strings(killed) + if len(killed) != 2 || killed[0] != "$1" || killed[1] != "$2" { + t.Errorf("killed = %v, want [$1 $2]", killed) + } +} + +// ── RemoteBranches ──────────────────────────────────────────────────────────── + +func TestRemoteBranches_fetchesThenLists(t *testing.T) { + g := &fakeGit{ListRemoteBranchesFn: func(string) ([]git.RemoteBranch, error) { + return []git.RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}, nil + }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + got, err := h.RemoteBranches("myapp", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !g.called("FetchAll") { + t.Error("expected FetchAll to be called") + } + if len(got) != 1 || got[0].Ref != "origin/feat-x" { + t.Errorf("branches = %+v", got) + } +} + +func TestRemoteBranches_noFetch(t *testing.T) { + g := &fakeGit{ListRemoteBranchesFn: func(string) ([]git.RemoteBranch, error) { + return []git.RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}, nil + }} + h, tmpDir := workspaceHerd(t, g, &fakeTmux{}) + if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil { + t.Fatal(err) + } + got, err := h.RemoteBranches("myapp", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if g.called("FetchAll") { + t.Error("no-fetch RemoteBranches must not fetch") + } + if len(got) != 1 { + t.Errorf("branches = %+v", got) + } +} + +func TestRemoteBranches_notCloned(t *testing.T) { + h, _ := workspaceHerd(t, &fakeGit{}, &fakeTmux{}) + _, err := h.RemoteBranches("myapp", false) + if !errors.Is(err, ErrNotCloned) { + t.Errorf("expected ErrNotCloned, got %v", err) + } +} + +// ── The defect, stated as tests ─────────────────────────────────────────────── + +// The defect, stated as a test. A worktree deleted under an active profile +// must take its sessions with it. worktree.Delete rebuilt the names with +// SessionName("", …), searched for myapp-feat, missed work-myapp-feat, and +// force-deleted the worktree anyway — leaving the agent process alive against +// a directory that no longer existed. +func TestTeardown_underProfile_killsSessionsThenDeletesWorktree(t *testing.T) { + g := &fakeGit{} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Profile: "work", Branch: "feat", Project: "myapp"}, + {ID: "$2", Name: "work-myapp-feat~sh", Canonical: "work-myapp-feat", + Type: "shell", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: g}) + + wtPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat") + if err := os.MkdirAll(wtPath, 0o755); err != nil { + t.Fatal(err) + } + + if err := h.Teardown(h.Ref("myapp", "feat"), TeardownOpts{Force: true}); err != nil { + t.Fatalf("Teardown: %v", err) + } + + killed := f.killed() + sort.Strings(killed) + if len(killed) != 2 || killed[0] != "$1" || killed[1] != "$2" { + t.Errorf("killed = %v, want [$1 $2]; a missed kill orphans the agent process", killed) + } + if !g.called("Remove", wtPath) { + t.Errorf("worktree was not removed; calls=%v", g.Calls) + } +} + +// Without --force, a running session blocks the delete rather than being +// killed under the user. +func TestTeardown_runningSessionWithoutForce(t *testing.T) { + g := &fakeGit{} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "myapp-feat", Canonical: "myapp-feat", + Type: "agent", Branch: "feat", Project: "myapp"}, + }} + h, dir := workspaceHerd(t, g, f) + if err := os.MkdirAll(filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat"), 0o755); err != nil { + t.Fatal(err) + } + + err := h.Teardown(h.Ref("myapp", "feat"), TeardownOpts{}) + if !errors.Is(err, ErrSessionRunning) { + t.Fatalf("err = %v, want ErrSessionRunning", err) + } + if len(f.killed()) != 0 { + t.Errorf("killed %v without --force", f.killed()) + } + if g.called("Remove") { + t.Error("worktree was removed despite a running session") + } +} + +// A non-force Teardown must refuse when a pre-upgrade session (empty Project, +// real stored Canonical) is running — same as it does for a normal session. +// Before matching on the stored canonical, the pre-check missed it and let the +// delete proceed. +func TestTeardown_nonForce_preUpgradeSessionRunning_refuses(t *testing.T) { + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: ""}, + }} + dir := t.TempDir() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: &fakeGit{}}) + + // Teardown stats the worktree path before the running-check, so create it. + if err := os.MkdirAll(filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat"), 0o755); err != nil { + t.Fatal(err) + } + + if err := h.Teardown(h.Ref("myapp", "feat"), TeardownOpts{}); !errors.Is(err, ErrSessionRunning) { + t.Fatalf("Teardown err = %v, want ErrSessionRunning", err) + } +} + +// List joins worktrees to sessions on the Ref, so the join is profile-correct. +// worktree.Service.List hardcoded SessionName("", …) at line 593, which is why +// `ch list worktree`'s "(running)" marker never appeared under a profile. +func TestList_underProfile_findsRunningSession(t *testing.T) { + dir := t.TempDir() + cloneDir := filepath.Join(dir, "github.com", "user", "myapp") + wtPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } + + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + return []git.WorktreeInfo{{Path: wtPath, Branch: "feat"}}, nil + }} + f := &fakeTmux{Sessions: []sessionRow{ + {ID: "$1", Name: "work-myapp-feat", Canonical: "work-myapp-feat", + Type: "agent", Status: "running", Profile: "work", Branch: "feat", Project: "myapp"}, + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, &config.ProfileRegistry{Active: "work"}, Deps{Tmux: f, Git: g}) + + spaces, err := h.List("") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(spaces) != 1 { + t.Fatalf("got %d workspaces, want 1", len(spaces)) + } + if spaces[0].Agent == nil { + t.Fatal("running agent session not joined to its workspace under a profile") + } + if spaces[0].Agent.ID != "$1" { + t.Errorf("Agent.ID = %q, want $1", spaces[0].Agent.ID) + } +} + +// A diverged HEAD changes what we render, never what we address. This is the +// other half of the shipped defect: Item.Branch held the display branch and +// round-tripped into wtSvc.Delete. +func TestList_divergedHead_refKeepsIdentityBranch(t *testing.T) { + dir := t.TempDir() + cloneDir := filepath.Join(dir, "github.com", "user", "myapp") + wtPath := filepath.Join(dir, "github.com", "user", "myapp__worktrees", "feat") + if err := os.MkdirAll(cloneDir, 0o755); err != nil { + t.Fatal(err) + } + + g := &fakeGit{ListFn: func(string) ([]git.WorktreeInfo, error) { + // The worktree was created for "feat" but HEAD now sits on "other". + return []git.WorktreeInfo{{Path: wtPath, Branch: "other"}}, nil + }} + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: dir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + h := New(cfg, nil, Deps{Tmux: &fakeTmux{}, Git: g}) + + spaces, err := h.List("") + if err != nil { + t.Fatalf("List: %v", err) + } + if spaces[0].Ref.Branch != "feat" { + t.Errorf("Ref.Branch = %q, want %q — identity must survive divergence", spaces[0].Ref.Branch, "feat") + } + if spaces[0].DisplayBranch != "other" { + t.Errorf("DisplayBranch = %q, want %q", spaces[0].DisplayBranch, "other") + } + if spaces[0].HeadHint != "on other" { + t.Errorf("HeadHint = %q, want %q", spaces[0].HeadHint, "on other") + } +} + +// workspaceHerdIn is workspaceHerd but reusing an existing tmpDir so a ListFn +// closure can reference the clone path. +func workspaceHerdIn(t *testing.T, tmpDir string, g *fakeGit, f *fakeTmux) (*Herd, string) { + t.Helper() + cfg := &config.Config{ + Defaults: config.DefaultsConfig{ProjectsDir: tmpDir}, + Projects: map[string]config.ProjectConfig{ + "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, + }, + } + return New(cfg, nil, Deps{Tmux: f, Git: g}), tmpDir +} + +// contains reports whether s contains substr. +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/internal/project/project.go b/internal/project/project.go deleted file mode 100644 index c626ac9..0000000 --- a/internal/project/project.go +++ /dev/null @@ -1,169 +0,0 @@ -package project - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - - "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/semconv" -) - -// ErrAlreadyCloned is returned by Clone when the target path already exists. -var ErrAlreadyCloned = errors.New("already cloned") - -// AlreadyClonedError carries the path that already exists. -type AlreadyClonedError struct{ Path string } - -func (e *AlreadyClonedError) Error() string { return e.Path + " already exists, skipping" } -func (e *AlreadyClonedError) Unwrap() error { return ErrAlreadyCloned } - -// GitRunner abstracts git clone execution to enable testing. -type GitRunner interface { - Clone(repo, path, branch string) error -} - -// RealGitRunner runs git commands via os/exec. -type RealGitRunner struct{} - -// NewRealGitRunner returns a GitRunner backed by the system git binary. -func NewRealGitRunner() *RealGitRunner { return &RealGitRunner{} } - -// Clone runs git clone. If branch is non-empty, passes --branch . -func (r *RealGitRunner) Clone(repo, path, branch string) error { - args := []string{"clone"} - if branch != "" { - args = append(args, "--branch", branch) - } - args = append(args, repo, path) - cmd := exec.Command("git", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git clone: %w\n%s", err, out) - } - return nil -} - -// ProjectEntry is a project with its derived filesystem path and clone status. -type ProjectEntry struct { - Name string - Config config.ProjectConfig - Path string // absolute path derived from repo URL + projects_dir - Cloned bool // true if Path exists on the filesystem -} - -// CloneResult captures the outcome of a single clone attempt. -type CloneResult struct { - Name string - Err error // nil=success, ErrAlreadyCloned=skipped, other=failure -} - -// Service provides project management operations. -type Service struct { - cfg *config.Config - git GitRunner - hook hooks.Hook -} - -// NewService creates a Service using the given config and GitRunner. -func NewService(cfg *config.Config, git GitRunner, hook hooks.Hook) *Service { - return &Service{cfg: cfg, git: git, hook: hook} -} - -// List returns all configured projects sorted by name. No filesystem access. -func (s *Service) List() []ProjectEntry { - entries := make([]ProjectEntry, 0, len(s.cfg.Projects)) - for name, p := range s.cfg.Projects { - var path string - if rp, err := config.RepoPath(p.Repo); err == nil { - path = filepath.Join(s.cfg.Defaults.ProjectsDir, rp) - } - entries = append(entries, ProjectEntry{ - Name: name, - Config: p, - Path: path, - }) - } - sort.Slice(entries, func(i, j int) bool { - return entries[i].Name < entries[j].Name - }) - return entries -} - -// Show returns the full entry for a single project, including Cloned status. -func (s *Service) Show(name string) (ProjectEntry, error) { - p, ok := s.cfg.Projects[name] - if !ok { - return ProjectEntry{}, fmt.Errorf("project %q is not configured", name) - } - repoPath, err := config.RepoPath(p.Repo) - if err != nil { - return ProjectEntry{}, fmt.Errorf("cannot parse repo URL %q: %w", p.Repo, err) - } - absPath := filepath.Join(s.cfg.Defaults.ProjectsDir, repoPath) - _, statErr := os.Stat(absPath) - return ProjectEntry{ - Name: name, - Config: p, - Path: absPath, - Cloned: statErr == nil, - }, nil -} - -// Clone clones a single project into its derived path under projects_dir. -// Returns *AlreadyClonedError (wrapping ErrAlreadyCloned) if the path exists. -func (s *Service) Clone(name string) error { - p, ok := s.cfg.Projects[name] - if !ok { - return fmt.Errorf("project %q is not configured", name) - } - repoPath, err := config.RepoPath(p.Repo) - if err != nil { - return fmt.Errorf("cannot parse repo URL %q: %w", p.Repo, err) - } - absPath := filepath.Join(s.cfg.Defaults.ProjectsDir, repoPath) - if _, err := os.Stat(absPath); err == nil { - return &AlreadyClonedError{Path: absPath} - } - - attrs := map[string]string{ - semconv.HookAttrProject: name, - semconv.HookAttrRepo: p.Repo, - semconv.HookAttrCloneDir: absPath, - } - - if err := s.hook.Trigger(semconv.HookPreClone, attrs, s.cfg.Defaults.ProjectsDir); err != nil { - return fmt.Errorf("pre-clone hook: %w", err) - } - - if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { - return fmt.Errorf("creating parent directories: %w", err) - } - if err := s.git.Clone(p.Repo, absPath, p.DefaultBranch); err != nil { - return fmt.Errorf("cloning repository: %w", err) - } - - if err := s.hook.Trigger(semconv.HookPostClone, attrs, s.cfg.Defaults.ProjectsDir); err != nil { - return fmt.Errorf("post-clone hook: %w", err) - } - - return nil -} - -// CloneAll clones all configured projects in sorted order. -func (s *Service) CloneAll() []CloneResult { - names := make([]string, 0, len(s.cfg.Projects)) - for name := range s.cfg.Projects { - names = append(names, name) - } - sort.Strings(names) - results := make([]CloneResult, 0, len(names)) - for _, name := range names { - results = append(results, CloneResult{Name: name, Err: s.Clone(name)}) - } - return results -} diff --git a/internal/project/project_test.go b/internal/project/project_test.go deleted file mode 100644 index b63cfc6..0000000 --- a/internal/project/project_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package project_test - -import ( - "errors" - "fmt" - "os" - "testing" - - "github.com/xico42/codeherd/internal/config" - "github.com/xico42/codeherd/internal/project" - "github.com/xico42/codeherd/internal/semconv" -) - -// mockGitRunner records Clone calls and returns controlled errors. -type mockGitRunner struct { - calls []cloneCall - errors map[string]error // keyed by repo -} - -type cloneCall struct{ Repo, Path, Branch string } - -func (m *mockGitRunner) Clone(repo, path, branch string) error { - m.calls = append(m.calls, cloneCall{repo, path, branch}) - if m.errors != nil { - return m.errors[repo] - } - return nil -} - -type mockHook struct { - calls []hookCall - failOn string -} - -type hookCall struct { - name string - attrs map[string]string - workDir string -} - -func (m *mockHook) Trigger(name string, attrs map[string]string, workDir string) error { - m.calls = append(m.calls, hookCall{name, attrs, workDir}) - if m.failOn == name { - return fmt.Errorf("hook %s failed", name) - } - return nil -} - -func makeConfig(projectsDir string, projects map[string]config.ProjectConfig) *config.Config { - cfg := &config.Config{} - cfg.Defaults.ProjectsDir = projectsDir - cfg.Projects = projects - return cfg -} - -func TestList_SortedByName(t *testing.T) { - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{ - "zebra": {Repo: "git@github.com:user/zebra.git", DefaultBranch: "main"}, - "alpha": {Repo: "git@github.com:user/alpha.git", DefaultBranch: "develop"}, - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - entries := svc.List() - - if len(entries) != 3 { - t.Fatalf("got %d entries, want 3", len(entries)) - } - if entries[0].Name != "alpha" || entries[1].Name != "myapp" || entries[2].Name != "zebra" { - t.Errorf("wrong order: %v", []string{entries[0].Name, entries[1].Name, entries[2].Name}) - } -} - -func TestList_PathDerivedFromRepo(t *testing.T) { - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - entries := svc.List() - - want := "/home/user/projects/github.com/user/myapp" - if entries[0].Path != want { - t.Errorf("Path = %q, want %q", entries[0].Path, want) - } -} - -func TestList_ClonedAlwaysFalse(t *testing.T) { - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - entries := svc.List() - if entries[0].Cloned { - t.Error("List should not check filesystem; Cloned should be false") - } -} - -func TestList_Empty(t *testing.T) { - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{}) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - entries := svc.List() - if len(entries) != 0 { - t.Errorf("got %d entries, want 0", len(entries)) - } -} - -func TestShow_ValidProject(t *testing.T) { - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, - }) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - e, err := svc.Show("myapp") - if err != nil { - t.Fatalf("Show() error = %v", err) - } - if e.Name != "myapp" { - t.Errorf("Name = %q, want %q", e.Name, "myapp") - } - if e.Path != "/home/user/projects/github.com/user/myapp" { - t.Errorf("Path = %q", e.Path) - } - // Cloned=false because path doesn't exist on this machine in tests -} - -func TestShow_UnknownProject(t *testing.T) { - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{}) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - _, err := svc.Show("nonexistent") - if err == nil { - t.Fatal("expected error for unknown project") - } -} - -func TestShow_ClonedTrue_WhenPathExists(t *testing.T) { - dir := t.TempDir() - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - // Create the expected path so Cloned=true - expectedPath := dir + "/github.com/user/myapp" - if err := os.MkdirAll(expectedPath, 0o755); err != nil { - t.Fatal(err) - } - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - e, err := svc.Show("myapp") - if err != nil { - t.Fatalf("Show() error = %v", err) - } - if !e.Cloned { - t.Error("Cloned should be true when path exists") - } -} - -func TestClone_HappyPath(t *testing.T) { - dir := t.TempDir() - mock := &mockGitRunner{} - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, - }) - svc := project.NewService(cfg, mock, &mockHook{}) - if err := svc.Clone("myapp"); err != nil { - t.Fatalf("Clone() error = %v", err) - } - if len(mock.calls) != 1 { - t.Fatalf("expected 1 git call, got %d", len(mock.calls)) - } - call := mock.calls[0] - if call.Repo != "git@github.com:user/myapp.git" { - t.Errorf("Repo = %q", call.Repo) - } - if call.Path != dir+"/github.com/user/myapp" { - t.Errorf("Path = %q", call.Path) - } - if call.Branch != "main" { - t.Errorf("Branch = %q, want %q", call.Branch, "main") - } -} - -func TestClone_NoBranch(t *testing.T) { - dir := t.TempDir() - mock := &mockGitRunner{} - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - svc := project.NewService(cfg, mock, &mockHook{}) - if err := svc.Clone("myapp"); err != nil { - t.Fatalf("Clone() error = %v", err) - } - if mock.calls[0].Branch != "" { - t.Errorf("Branch should be empty when default_branch not set") - } -} - -func TestClone_AlreadyCloned(t *testing.T) { - dir := t.TempDir() - mock := &mockGitRunner{} - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - // Pre-create the target path - targetPath := dir + "/github.com/user/myapp" - if err := os.MkdirAll(targetPath, 0o755); err != nil { - t.Fatal(err) - } - svc := project.NewService(cfg, mock, &mockHook{}) - err := svc.Clone("myapp") - if !errors.Is(err, project.ErrAlreadyCloned) { - t.Fatalf("want ErrAlreadyCloned, got %v", err) - } - if len(mock.calls) != 0 { - t.Error("git should not be called when path already exists") - } - var ace *project.AlreadyClonedError - if !errors.As(err, &ace) { - t.Fatal("want *AlreadyClonedError") - } - if ace.Path != targetPath { - t.Errorf("AlreadyClonedError.Path = %q, want %q", ace.Path, targetPath) - } -} - -func TestClone_UnknownProject(t *testing.T) { - cfg := makeConfig("/tmp", map[string]config.ProjectConfig{}) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - err := svc.Clone("nonexistent") - if err == nil { - t.Fatal("expected error for unknown project") - } -} - -func TestClone_GitFailure(t *testing.T) { - dir := t.TempDir() - gitErr := fmt.Errorf("repository not found") - mock := &mockGitRunner{errors: map[string]error{ - "git@github.com:user/myapp.git": gitErr, - }} - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - svc := project.NewService(cfg, mock, &mockHook{}) - err := svc.Clone("myapp") - if err == nil { - t.Fatal("expected error on git failure") - } -} - -func TestCloneAll_MixedResults(t *testing.T) { - dir := t.TempDir() - mock := &mockGitRunner{errors: map[string]error{ - "git@github.com:user/fail.git": fmt.Errorf("auth failed"), - }} - // Pre-create path for "existing" project - if err := os.MkdirAll(dir+"/github.com/user/existing", 0o755); err != nil { - t.Fatal(err) - } - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "alpha": {Repo: "git@github.com:user/alpha.git"}, - "existing": {Repo: "git@github.com:user/existing.git"}, - "fail": {Repo: "git@github.com:user/fail.git"}, - }) - svc := project.NewService(cfg, mock, &mockHook{}) - results := svc.CloneAll() - - if len(results) != 3 { - t.Fatalf("got %d results, want 3", len(results)) - } - // Results must be sorted: alpha, existing, fail - if results[0].Name != "alpha" || results[0].Err != nil { - t.Errorf("alpha: got name=%q err=%v", results[0].Name, results[0].Err) - } - if results[1].Name != "existing" || !errors.Is(results[1].Err, project.ErrAlreadyCloned) { - t.Errorf("existing: got name=%q err=%v", results[1].Name, results[1].Err) - } - if results[2].Name != "fail" || results[2].Err == nil { - t.Errorf("fail: got name=%q err=%v", results[2].Name, results[2].Err) - } - // Only "alpha" and "fail" should have triggered git calls - if len(mock.calls) != 2 { - t.Errorf("expected 2 git calls, got %d", len(mock.calls)) - } -} - -func TestCloneAll_Empty(t *testing.T) { - cfg := makeConfig("/tmp", map[string]config.ProjectConfig{}) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - results := svc.CloneAll() - if len(results) != 0 { - t.Errorf("got %d results, want 0", len(results)) - } -} - -// ── AlreadyClonedError ──────────────────────────────────────────────────────── - -func TestAlreadyClonedError_ErrorString(t *testing.T) { - err := &project.AlreadyClonedError{Path: "/some/path"} - want := "/some/path already exists, skipping" - if err.Error() != want { - t.Errorf("Error() = %q, want %q", err.Error(), want) - } -} - -func TestAlreadyClonedError_Unwrap(t *testing.T) { - err := &project.AlreadyClonedError{Path: "/some/path"} - if err.Unwrap() != project.ErrAlreadyCloned { - t.Errorf("Unwrap() = %v, want ErrAlreadyCloned", err.Unwrap()) - } -} - -// ── NewRealGitRunner ────────────────────────────────────────────────────────── - -func TestNewRealGitRunner_NotNil(t *testing.T) { - r := project.NewRealGitRunner() - if r == nil { - t.Error("NewRealGitRunner() = nil, want non-nil") - } -} - -// ── Show with bad repo URL ──────────────────────────────────────────────────── - -func TestShow_BadRepoURL(t *testing.T) { - // An https URL with no host triggers RepoPath's "no host" error. - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{ - "badrepo": {Repo: "https:///no-host/repo.git"}, - }) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - _, err := svc.Show("badrepo") - if err == nil { - t.Fatal("Show() with bad repo URL = nil, want error") - } -} - -// ── Clone with bad repo URL ─────────────────────────────────────────────────── - -func TestClone_BadRepoURL(t *testing.T) { - // An https URL with no host triggers RepoPath's "no host" error. - cfg := makeConfig("/home/user/projects", map[string]config.ProjectConfig{ - "badrepo": {Repo: "https:///no-host/repo.git"}, - }) - svc := project.NewService(cfg, &mockGitRunner{}, &mockHook{}) - err := svc.Clone("badrepo") - if err == nil { - t.Fatal("Clone() with bad repo URL = nil, want error") - } -} - -// ── Hook integration ────────────────────────────────────────────────────────── - -func TestClone_TriggersHooks(t *testing.T) { - dir := t.TempDir() - mock := &mockGitRunner{} - hookMock := &mockHook{} - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git", DefaultBranch: "main"}, - }) - svc := project.NewService(cfg, mock, hookMock) - if err := svc.Clone("myapp"); err != nil { - t.Fatalf("Clone() error = %v", err) - } - - if len(hookMock.calls) != 2 { - t.Fatalf("expected 2 hook calls, got %d", len(hookMock.calls)) - } - if hookMock.calls[0].name != semconv.HookPreClone { - t.Errorf("first hook = %q, want %q", hookMock.calls[0].name, semconv.HookPreClone) - } - if hookMock.calls[1].name != semconv.HookPostClone { - t.Errorf("second hook = %q, want %q", hookMock.calls[1].name, semconv.HookPostClone) - } - if hookMock.calls[0].attrs[semconv.HookAttrProject] != "myapp" { - t.Errorf("project attr = %q", hookMock.calls[0].attrs[semconv.HookAttrProject]) - } -} - -func TestClone_PreHookFailure_StopsClone(t *testing.T) { - dir := t.TempDir() - mock := &mockGitRunner{} - hookMock := &mockHook{failOn: semconv.HookPreClone} - cfg := makeConfig(dir, map[string]config.ProjectConfig{ - "myapp": {Repo: "git@github.com:user/myapp.git"}, - }) - svc := project.NewService(cfg, mock, hookMock) - err := svc.Clone("myapp") - if err == nil { - t.Error("expected error when pre-clone hook fails") - } - if len(mock.calls) != 0 { - t.Error("git clone should not be called when pre-clone hook fails") - } -} diff --git a/internal/semconv/semconv.go b/internal/semconv/semconv.go index 68c9726..038e801 100644 --- a/internal/semconv/semconv.go +++ b/internal/semconv/semconv.go @@ -15,6 +15,7 @@ const ( TmuxOptionSessionType = "@codeherd_session_type" TmuxOptionProfile = "@codeherd_profile" TmuxOptionBranch = "@codeherd_branch" + TmuxOptionProject = "@codeherd_project" StatusRunning = "running" StatusWaiting = "waiting" diff --git a/internal/session/session.go b/internal/session/session.go deleted file mode 100644 index ff4b7a7..0000000 --- a/internal/session/session.go +++ /dev/null @@ -1,350 +0,0 @@ -package session - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/xico42/codeherd/internal/hooks" - "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/tmux" -) - -var ( - ErrSessionExists = errors.New("session already exists") - ErrSessionNotFound = errors.New("session not found") - ErrPathNotFound = errors.New("worktree path not found") -) - -// SessionExistsError is returned by Start when a tmux session for the same -// (project, branch, type) already exists. It wraps ErrSessionExists for -// errors.Is compatibility and carries Project/Branch/Type for structured -// access by callers (e.g. to print an attach hint). -type SessionExistsError struct { - Project string - Branch string - Type string -} - -func (e *SessionExistsError) Error() string { - return fmt.Sprintf("%s: %s/%s (%s)", ErrSessionExists.Error(), e.Project, e.Branch, e.Type) -} - -func (e *SessionExistsError) Unwrap() error { - return ErrSessionExists -} - -// Service manages codeherd tmux sessions and their persisted state. -type Service struct { - tmux *tmux.Client - hook hooks.Hook -} - -// NewService creates a Service using the given tmux client. -func NewService(tmux *tmux.Client, hook hooks.Hook) *Service { - return &Service{tmux: tmux, hook: hook} -} - -// StartRequest holds parameters for starting a new session. -type StartRequest struct { - Project string - Branch string - Path string - CloneDir string // main git clone for the project (exposed as CODEHERD_CLONE_DIR) - Type string // semconv.SessionTypeAgent or SessionTypeShell; defaults to SessionTypeAgent - Cmd string - Env map[string]string - Attach bool - Profile string // "" when profiles are disabled -} - -// Start creates a new detached tmux session for the given project/branch and -// sets @codeherd_status and @codeherd_started_at tmux options on the new session. -// The session command runs with these env vars, which override any conflicting -// keys in req.Env: -// -// - CODEHERD_SESSION canonical session name -// - CODEHERD_PROJECT project name -// - CODEHERD_BRANCH branch name -// - CODEHERD_CLONE_DIR main git clone path (when req.CloneDir is set) -// - CODEHERD_WORKTREE_PATH worktree root -// - CODEHERD_PROFILE profile name (only when req.Profile is non-empty) -// -// Returns ErrSessionExists if a session with the same canonical name and type already exists. -// Returns ErrPathNotFound if Path does not exist on disk. -func (s *Service) Start(req StartRequest) (string, error) { - if req.Type == "" { - req.Type = semconv.SessionTypeAgent - } - - // Canonical name is <[profile-]project-branch>; tmux name differs by type. - canonicalName := semconv.SessionName(req.Profile, req.Project, req.Branch) - var tmuxName string - if req.Type == semconv.SessionTypeShell { - tmuxName = semconv.ShellSessionName(req.Profile, req.Project, req.Branch) - } else { - tmuxName = canonicalName - } - - // Scope existence check to (canonical name, type) pair so agent and shell sessions coexist. - records, err := s.tmux.ListSessions() - if err != nil { - return "", fmt.Errorf("checking session: %w", err) - } - for _, r := range records { - if r.CanonicalName == canonicalName && r.SessionType == req.Type { - return "", &SessionExistsError{Project: req.Project, Branch: req.Branch, Type: req.Type} - } - } - - if _, err := os.Stat(req.Path); err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("%w: %s", ErrPathNotFound, req.Path) - } - return "", fmt.Errorf("checking path: %w", err) - } - - attrs := map[string]string{ - semconv.HookAttrProject: req.Project, - semconv.HookAttrBranch: req.Branch, - semconv.HookAttrWorktreePath: req.Path, - semconv.HookAttrSessionName: canonicalName, - } - - if err := s.hook.Trigger(semconv.HookPreSession, attrs, req.Path); err != nil { - return "", fmt.Errorf("pre-session hook: %w", err) - } - - env := make(map[string]string) - for k, v := range req.Env { - env[k] = v - } - // Codeherd-stamped vars win over user-supplied Env. - env[semconv.SessionEnvVar] = canonicalName - env[semconv.HookAttrProject] = req.Project - env[semconv.HookAttrBranch] = req.Branch - env[semconv.HookAttrWorktreePath] = req.Path - if req.CloneDir != "" { - env[semconv.HookAttrCloneDir] = req.CloneDir - } - if req.Profile != "" { - env[semconv.EnvProfile] = req.Profile - } - - // Capture the stable session ID atomically at creation; a separate - // display-message round-trip would race with short-lived commands (e.g. "true"). - id, err := s.tmux.NewSessionWithEnv(tmuxName, req.Path, env, req.Cmd) - if err != nil { - return "", fmt.Errorf("creating tmux session: %w", err) - } - - now := time.Now().UTC() - _ = s.tmux.SetOption(tmuxName, semconv.TmuxOptionStatus, semconv.StatusRunning) - _ = s.tmux.SetOption(tmuxName, semconv.TmuxOptionStartedAt, now.Format(time.RFC3339)) - _ = s.tmux.SetOption(tmuxName, semconv.TmuxOptionCanonicalName, canonicalName) - _ = s.tmux.SetOption(tmuxName, semconv.TmuxOptionSessionType, req.Type) - _ = s.tmux.SetOption(tmuxName, semconv.TmuxOptionBranch, req.Branch) - if req.Profile != "" { - _ = s.tmux.SetOption(tmuxName, semconv.TmuxOptionProfile, req.Profile) - } - - if err := s.hook.Trigger(semconv.HookPostSession, attrs, req.Path); err != nil { - return "", fmt.Errorf("post-session hook: %w", err) - } - - return id, nil -} - -// SessionInfo holds display information about a tmux session. -type SessionInfo struct { - Name string - TmuxName string // actual tmux session name (may have status prefix) - SessionID string // tmux session_id — stable target for attach/switch - Type string // semconv.SessionTypeAgent or SessionTypeShell - Project string - Branch string - Status string - Annotation string - StartedAt time.Time - UpdatedAt time.Time - Profile string -} - -// List returns a SessionInfo for every active tmux session, including both agent and shell types. -func (s *Service) List() ([]SessionInfo, error) { - records, err := s.tmux.ListSessions() - if err != nil { - return nil, fmt.Errorf("listing tmux sessions: %w", err) - } - - var result []SessionInfo - for _, r := range records { - info := SessionInfo{ - Name: r.CanonicalName, - Type: r.SessionType, - Status: r.Status, - Annotation: r.Annotation, - Profile: r.Profile, - } - if r.StartedAt != "" { - info.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) - } - result = append(result, info) - } - return result, nil -} - -// Show returns the SessionInfo for a session identified by project, branch, and type. -// Empty sessionType defaults to semconv.SessionTypeAgent. -// Returns ErrSessionNotFound if no matching session exists. -func (s *Service) Show(project, branch, sessionType string) (*SessionInfo, error) { - if sessionType == "" { - sessionType = semconv.SessionTypeAgent - } - canonicalName := semconv.SessionName("", project, branch) - records, err := s.tmux.ListSessions() - if err != nil { - return nil, fmt.Errorf("listing sessions: %w", err) - } - for _, r := range records { - if r.CanonicalName == canonicalName && r.SessionType == sessionType { - info := &SessionInfo{ - Name: r.CanonicalName, - TmuxName: r.Name, - SessionID: r.ID, - Type: r.SessionType, - Status: r.Status, - Annotation: r.Annotation, - Profile: r.Profile, - } - if r.StartedAt != "" { - info.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) - } - return info, nil - } - } - return nil, fmt.Errorf("%w: %s/%s (%s)", ErrSessionNotFound, project, branch, sessionType) -} - -// ShowByName returns the SessionInfo for the session whose canonical -// name + type match exactly. Returns ErrSessionNotFound otherwise. -func (s *Service) ShowByName(name, sessionType string) (*SessionInfo, error) { - if sessionType == "" { - sessionType = semconv.SessionTypeAgent - } - records, err := s.tmux.ListSessions() - if err != nil { - return nil, fmt.Errorf("listing sessions: %w", err) - } - for _, r := range records { - if r.CanonicalName == name && r.SessionType == sessionType { - info := &SessionInfo{ - Name: r.CanonicalName, - TmuxName: r.Name, - SessionID: r.ID, - Type: r.SessionType, - Status: r.Status, - Annotation: r.Annotation, - Profile: r.Profile, - } - if r.StartedAt != "" { - info.StartedAt, _ = time.Parse(time.RFC3339, r.StartedAt) - } - return info, nil - } - } - return nil, fmt.Errorf("%w: %s (%s)", ErrSessionNotFound, name, sessionType) -} - -// StopByName kills the session whose canonical name + type match exactly. -// Empty sessionType defaults to semconv.SessionTypeAgent. -// Returns ErrSessionNotFound if no matching session exists. -func (s *Service) StopByName(name, sessionType string) error { - if sessionType == "" { - sessionType = semconv.SessionTypeAgent - } - records, err := s.tmux.ListSessions() - if err != nil { - return fmt.Errorf("listing sessions: %w", err) - } - actualName := "" - for _, r := range records { - if r.CanonicalName == name && r.SessionType == sessionType { - actualName = r.Name - break - } - } - if actualName == "" { - return fmt.Errorf("%w: %s (%s)", ErrSessionNotFound, name, sessionType) - } - if err := s.tmux.KillSession(actualName); err != nil { - return fmt.Errorf("killing session: %w", err) - } - return nil -} - -// Stop kills the tmux session identified by project, branch, and type. -// Empty sessionType defaults to semconv.SessionTypeAgent. -// Returns ErrSessionNotFound if no matching session exists. -func (s *Service) Stop(project, branch, sessionType string) error { - if sessionType == "" { - sessionType = semconv.SessionTypeAgent - } - canonicalName := semconv.SessionName("", project, branch) - records, err := s.tmux.ListSessions() - if err != nil { - return fmt.Errorf("listing sessions: %w", err) - } - actualName := "" - for _, r := range records { - if r.CanonicalName == canonicalName && r.SessionType == sessionType { - actualName = r.Name - break - } - } - if actualName == "" { - return fmt.Errorf("%w: %s/%s (%s)", ErrSessionNotFound, project, branch, sessionType) - } - if err := s.tmux.KillSession(actualName); err != nil { - return fmt.Errorf("killing session: %w", err) - } - return nil -} - -// SetStatus transitions a session's status and updates the annotation. -// It resolves the actual tmux session name by canonical name. -// Errors are suppressed — this method always returns nil. -func (s *Service) SetStatus(name, status, annotation string) error { - if name == "" { - return nil - } - if status != semconv.StatusRunning && status != semconv.StatusWaiting { - return nil - } - - records, _ := s.tmux.ListSessions() - actualName := "" - for _, r := range records { - if r.CanonicalName == name && r.SessionType == semconv.SessionTypeAgent { - actualName = r.Name - break - } - } - if actualName == "" { - return nil // session not found, suppress - } - - _ = s.tmux.SetOption(actualName, semconv.TmuxOptionStatus, status) - _ = s.tmux.SetOption(actualName, semconv.TmuxOptionAnnotation, annotation) - - hasPrefix := strings.HasPrefix(actualName, semconv.StatusPrefix) - if status == semconv.StatusRunning && hasPrefix { - _ = s.tmux.RenameSession(actualName, strings.TrimPrefix(actualName, semconv.StatusPrefix)) - } else if status != semconv.StatusRunning && !hasPrefix { - _ = s.tmux.RenameSession(actualName, semconv.StatusPrefix+actualName) - } - - return nil -} diff --git a/internal/session/session_test.go b/internal/session/session_test.go deleted file mode 100644 index de82283..0000000 --- a/internal/session/session_test.go +++ /dev/null @@ -1,1124 +0,0 @@ -package session_test - -import ( - "errors" - "fmt" - "sort" - "strings" - "testing" - - "github.com/xico42/codeherd/internal/semconv" - "github.com/xico42/codeherd/internal/session" - "github.com/xico42/codeherd/internal/tmux" -) - -// findCall reports whether any recorded tmux invocation contains all -// the given substrings in order. -func findCall(calls [][]string, want ...string) bool { - for _, c := range calls { - joined := strings.Join(c, " ") - ok := true - for _, w := range want { - if !strings.Contains(joined, w) { - ok = false - break - } - } - if ok { - return true - } - } - return false -} - -// newSessionEnv extracts the map of KEY=VALUE pairs passed via -e flags to the -// tmux new-session call, or nil if no such call is in the recorded invocations. -func newSessionEnv(calls [][]string) map[string]string { - for _, c := range calls { - if len(c) == 0 || c[0] != "new-session" { - continue - } - env := map[string]string{} - for i := 0; i < len(c)-1; i++ { - if c[i] != "-e" { - continue - } - kv := c[i+1] - eq := strings.Index(kv, "=") - if eq < 0 { - continue - } - env[kv[:eq]] = kv[eq+1:] - } - return env - } - return nil -} - -// mockRunner implements tmux.Runner for testing. -type mockRunner struct { - stdout string - stderr string - exitCode int - err error - calls [][]string -} - -func (m *mockRunner) Run(args ...string) (string, string, int, error) { - m.calls = append(m.calls, args) - return m.stdout, m.stderr, m.exitCode, m.err -} - -type mockHook struct { - calls []hookCall - failOn string -} -type hookCall struct { - name string - attrs map[string]string - workDir string -} - -func (m *mockHook) Trigger(name string, attrs map[string]string, workDir string) error { - m.calls = append(m.calls, hookCall{name, attrs, workDir}) - if m.failOn == name { - return fmt.Errorf("hook %s failed", name) - } - return nil -} - -func newService(t *testing.T, r *mockRunner) *session.Service { - t.Helper() - tc := tmux.NewClient(r) - return session.NewService(tc, &mockHook{}) -} - -func TestStart_OK(t *testing.T) { - r2 := &mockRunnerSequence{responses: []mockResponse{ - {exitCode: 1}, // list-sessions → no sessions (exit 1 = empty) - {exitCode: 0, stdout: "$1\n"}, // new-session → ok, returns session_id via -P -F - {exitCode: 0}, // set-option status - {exitCode: 0}, // set-option started_at - {exitCode: 0}, // set-option canonical_name - {exitCode: 0}, // set-option session_type - {exitCode: 0}, // set-option branch - }} - tc := tmux.NewClient(r2) - svc := session.NewService(tc, &mockHook{}) - - wtDir := t.TempDir() - sessionID, err := svc.Start(session.StartRequest{ - Project: "myapp", - Branch: "feature", - Path: wtDir, - Cmd: "claude", - Env: map[string]string{"FOO": "bar"}, - }) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - if sessionID != "$1" { - t.Errorf("Start() sessionID = %q, want $1", sessionID) - } - if len(r2.calls) != 7 { - t.Errorf("expected 7 tmux calls, got %d: %v", len(r2.calls), r2.calls) - } -} - -func TestStart_StampsBranchOption(t *testing.T) { - r := &mockRunnerSequence{responses: []mockResponse{ - {exitCode: 1}, // list-sessions → empty - {exitCode: 0, stdout: "$1\n"}, // new-session → ok - {exitCode: 0}, // set-option status - {exitCode: 0}, // set-option started_at - {exitCode: 0}, // set-option canonical_name - {exitCode: 0}, // set-option session_type - {exitCode: 0}, // set-option branch - }} - tc := tmux.NewClient(r) - svc := session.NewService(tc, &mockHook{}) - - if _, err := svc.Start(session.StartRequest{ - Project: "myapp", - Branch: "feature/login", - Path: t.TempDir(), - Cmd: "claude", - }); err != nil { - t.Fatalf("Start() error = %v", err) - } - - found := false - for _, call := range r.calls { - // SetOption runs: ("set-option", "-t", ,