diff --git a/CLAUDE.md b/CLAUDE.md
index de927ba..d97764e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,5 +92,6 @@ All commands run locally — they execute git/tmux/filesystem directly on whatev
- **`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
- **Integration tests**: tagged with `//go:build integration` and run separately via `make test-integration`
+- **Pin the git default branch in test repos**: helpers that `git init` a repo for worktree/session tests must pass `-b main` (e.g. `git init -b main
`). Plain `git init` takes the branch name from the host's `init.defaultBranch`, which is unset on CI runners and defaults to `master`. Code that references `main` as a start point (e.g. `worktree.New`'s fallback) then fails with `fatal: invalid reference: main` on CI while passing on a developer machine configured with `init.defaultBranch=main`.
- **Tmux isolation in tests**: tests that touch a real tmux server must isolate it. Set `CODEHERD_TMUX_SOCKET` to a path under `t.TempDir()` (the `internal/tmux` `RealRunner` reads this env var and prepends `-S ` to every tmux call), also clear `$TMUX` so tmux does not think it is nested, and probe with a throwaway `tmux -S new-session` — `t.Skip` if it fails so sandboxed CI environments do not flake. Cleanup must `tmux -S kill-server`. The `cmd_test` package exposes `useIsolatedTmux(t)` + `tmuxCmd(t, args...)` helpers; tests in `package cmd` inline the same setup. Never call bare `exec.Command("tmux", …)` from a test — it will leak into the developer's outer tmux server and collide with parallel test runs.
diff --git a/cmd/completion.go b/cmd/completion.go
index ac77482..c504b60 100644
--- a/cmd/completion.go
+++ b/cmd/completion.go
@@ -104,6 +104,31 @@ func branchNames(entries []worktree.ListEntry) []string {
return names
}
+// 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)
+}
+
+// completeRemoteBranches completes the --track flag against the remote-tracking
+// branches of the project named in args[0].
+func completeRemoteBranches(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
+ if len(args) == 0 {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+ branches, err := completionRemoteBrancher(loadCompletionConfig(cmd), args[0])
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+ refs := make([]string, 0, len(branches))
+ for _, b := range branches {
+ refs = append(refs, b.Ref)
+ }
+ sort.Strings(refs)
+ return refs, cobra.ShellCompDirectiveNoFileComp
+}
+
// completeProjectThenBranch completes the positional (arg 0),
// then the positional (arg 1) against that project's worktrees.
// Used by commands operating on an existing branch.
diff --git a/cmd/completion_internal_test.go b/cmd/completion_internal_test.go
index cd0e06b..f4c5343 100644
--- a/cmd/completion_internal_test.go
+++ b/cmd/completion_internal_test.go
@@ -19,6 +19,35 @@ func withStubConfig(t *testing.T, c *config.Config) {
t.Cleanup(func() { loadCompletionConfig = orig })
}
+func TestCompleteRemoteBranches(t *testing.T) {
+ orig := completionRemoteBrancher
+ t.Cleanup(func() { completionRemoteBrancher = orig })
+ completionRemoteBrancher = func(_ *config.Config, project string) ([]worktree.RemoteBranch, error) {
+ return []worktree.RemoteBranch{
+ {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"},
+ {Remote: "upstream", Branch: "fix-y", Ref: "upstream/fix-y"},
+ }, nil
+ }
+
+ cmd := (&CreateWorktreeCmd{}).Cobra()
+ got, directive := completeRemoteBranches(cmd, []string{"myapp"}, "")
+ if directive != cobra.ShellCompDirectiveNoFileComp {
+ t.Errorf("directive = %v", directive)
+ }
+ want := []string{"origin/feat-x", "upstream/fix-y"}
+ if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ t.Errorf("completions = %v, want %v", got, want)
+ }
+}
+
+func TestCompleteRemoteBranches_noProject(t *testing.T) {
+ cmd := (&CreateWorktreeCmd{}).Cobra()
+ got, _ := completeRemoteBranches(cmd, nil, "")
+ if got != nil {
+ t.Errorf("expected nil completions with no project, got %v", got)
+ }
+}
+
func TestCompleteAgents(t *testing.T) {
withStubConfig(t, &config.Config{Agents: map[string]config.AgentConfig{
"claude": {Cmd: "claude"},
diff --git a/cmd/session_integration_test.go b/cmd/session_integration_test.go
index e45051e..0ffda67 100644
--- a/cmd/session_integration_test.go
+++ b/cmd/session_integration_test.go
@@ -37,7 +37,7 @@ func initBareRepo(t *testing.T, cloneDir string) {
t.Helper()
os.MkdirAll(cloneDir, 0o755)
cmds := [][]string{
- {"git", "init", cloneDir},
+ {"git", "init", "-b", "main", cloneDir},
{"git", "-C", cloneDir, "config", "user.email", "test@test.com"},
{"git", "-C", cloneDir, "config", "user.name", "Test"},
{"git", "-C", cloneDir, "commit", "--allow-empty", "-m", "init"},
diff --git a/cmd/worktree.go b/cmd/worktree.go
index d5883b0..c9f47fa 100644
--- a/cmd/worktree.go
+++ b/cmd/worktree.go
@@ -66,30 +66,40 @@ func (c *ListWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
type CreateWorktreeCmd struct {
From string
+ Track string
Attach bool
Agent string
}
func (c *CreateWorktreeCmd) Cobra() *cobra.Command {
cmd := &cobra.Command{
- Use: "worktree ",
+ Use: "worktree [branch]",
Aliases: []string{"worktrees", "wt"},
Short: "Create a new worktree for a project",
- Args: cobra.ExactArgs(2),
+ Args: cobra.RangeArgs(1, 2),
RunE: c.Run,
ValidArgsFunction: completeProjectOnly,
}
cmd.Flags().StringVar(&c.From, "from", "", "base branch to create worktree from")
+ cmd.Flags().StringVar(&c.Track, "track", "", "remote branch to fetch and check out (e.g. feat-x or upstream/feat-x)")
cmd.Flags().BoolVar(&c.Attach, "attach", false, "start a coding session after creation")
cmd.Flags().StringVar(&c.Agent, "agent", "", "agent to use for the session (with --attach)")
+ cmd.MarkFlagsMutuallyExclusive("from", "track")
_ = cmd.RegisterFlagCompletionFunc("from", completeBranches)
+ _ = cmd.RegisterFlagCompletionFunc("track", completeRemoteBranches)
_ = cmd.RegisterFlagCompletionFunc("agent", completeAgents)
return cmd
}
func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
- project, branch := args[0], args[1]
- fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, branch)
+ project := args[0]
+ posBranch := ""
+ if len(args) > 1 {
+ posBranch = args[1]
+ }
+ if c.Track == "" && posBranch == "" {
+ return fmt.Errorf("a argument is required unless --track is given")
+ }
projCfg := cfg.Projects[project]
h := hooks.New(projCfg.Hooks)
@@ -102,16 +112,24 @@ func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), h)
var result worktree.NewResult
var err error
- if c.From != "" {
- result, err = svc.NewFrom(project, branch, c.From)
- } else {
- result, err = svc.New(project, branch)
+ 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)
}
if err != nil {
fmt.Fprintln(cmd.OutOrStdout())
- return worktreeErr(cmd, project, branch, err)
+ return worktreeErr(cmd, project, posBranch, err)
}
+ branch := result.Branch
+
// File copy
if len(projCfg.Files) > 0 {
copySvc := filecopy.New(h)
diff --git a/cmd/worktree_internal_test.go b/cmd/worktree_internal_test.go
new file mode 100644
index 0000000..d9bf3fb
--- /dev/null
+++ b/cmd/worktree_internal_test.go
@@ -0,0 +1,36 @@
+package cmd
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCreateWorktree_trackFlagRegistered(t *testing.T) {
+ c := (&CreateWorktreeCmd{}).Cobra()
+ if c.Flags().Lookup("track") == nil {
+ t.Fatal("expected --track flag to be registered")
+ }
+}
+
+func TestCreateWorktree_trackAndFromMutuallyExclusive(t *testing.T) {
+ c := (&CreateWorktreeCmd{}).Cobra()
+ c.SetArgs([]string{"myapp", "--track", "feat-x", "--from", "main"})
+ c.SilenceUsage = true
+ c.SilenceErrors = true
+ err := c.Execute()
+ // Cobra's mutual-exclusion error names both flags in the group.
+ if err == nil || !strings.Contains(err.Error(), "from") || !strings.Contains(err.Error(), "track") {
+ t.Fatalf("expected mutual-exclusion error, got %v", err)
+ }
+}
+
+func TestCreateWorktree_branchRequiredWithoutTrack(t *testing.T) {
+ c := (&CreateWorktreeCmd{}).Cobra()
+ c.SetArgs([]string{"myapp"})
+ c.SilenceUsage = true
+ c.SilenceErrors = true
+ err := c.Execute()
+ if err == nil {
+ t.Fatal("expected error when no branch and no --track given")
+ }
+}
diff --git a/docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md b/docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md
new file mode 100644
index 0000000..e933fc5
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-27-checkout-remote-branch-worktree.md
@@ -0,0 +1,1834 @@
+# Checkout a Remote Branch into a New Worktree — 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:** Let users fetch and check out a remote branch into a new tracking worktree (for PR review), and make every worktree *creation* start from an up-to-date source.
+
+**Architecture:** Extend `internal/worktree`'s `WorktreeRunner` with git fetch / remote-listing / tracking-add primitives, add `Service` methods (`NewTracking`, `RemoteBranches`, `ListRemoteBranches`) plus a `freshenStartPoint` helper that all creation paths use. Surface it on the CLI via a `--track ` flag on `create worktree`, and in the TUI via a new remote-branch picker (`r`) that feeds the existing create-worktree form.
+
+**Tech Stack:** Go, Cobra (CLI), Bubble Tea v2 + Bubbles `list` + Huh v2 (TUI), git via `os/exec`.
+
+## Global Constraints
+
+- Go module `github.com/xico42/codeherd`; build/test via the `Makefile`.
+- New code must keep aggregate coverage ≥ 80%. Final gate: `make check` (coverage → integration tests → lint → build).
+- Follow existing patterns: struct-per-command CLI, `WorktreeRunner` interface mocked in tests, pure parse helpers tested directly (mirror `parseWorktreePorcelain`).
+- Default remote is `origin`, hardcoded (no config knob).
+- `--track` and `--from` are mutually exclusive.
+- Fetch/fast-forward during creation is **best-effort**: failures are non-fatal and never abort creation (except `NewTracking`'s primary fetch of the requested branch, which is fatal).
+- The new worktree must never lose un-pushed local commits: fast-forward is `--ff-only`, and when a local source branch exists the new branch is based off that (possibly fast-forwarded) local branch, not the remote-tracking ref.
+
+---
+
+## File Structure
+
+- `internal/worktree/worktree.go` — extend `WorktreeRunner`, `RealWorktreeRunner`, add `RemoteBranch` type, `NewResult.Branch`, `ErrLocalBranchExists`, pure helpers `parseRef`/`parseRemoteBranches`, `freshenStartPoint`, and `Service` methods `NewTracking`/`RemoteBranches`/`ListRemoteBranches`. Rewire `New`/`NewFrom`.
+- `internal/worktree/worktree_test.go` — extend `mockGit`; update affected tests; add new unit tests.
+- `cmd/worktree.go` — `--track` flag, optional positional, mutual exclusion, route to `NewTracking`, use `NewResult.Branch`.
+- `cmd/worktree_test.go` (or existing cmd tests) — CLI wiring tests.
+- `cmd/completion.go` — `completeRemoteBranches` + `completionRemoteBrancher` var.
+- `cmd/completion_test.go` — completion test.
+- `internal/tui/keys.go` — replace the `Refresh` binding with a `Remote` binding (`r`).
+- `internal/tui/remote_picker.go` (new) — `remotePickerModel`, `remoteBranchItem`.
+- `internal/tui/remote_picker_test.go` (new) — picker tests.
+- `internal/tui/model.go` — `screenRemotePicker`, `remoteBranchesMsg`, `remotePicker` field, `startRemotePicker`/`fetchRemoteBranchesCmd`/`updateRemotePicker`/`showTrackForm`, View + key dispatch.
+- `internal/tui/form.go` — `formContext.tracksRef`/`branch`, `formModel.tracksRef`, prefill + `NewTracking` routing.
+- `internal/tui/form_test.go`, `internal/tui/model_test.go` — TUI tests.
+- `internal/worktree/integration_test.go` (new) — end-to-end `//go:build integration` test.
+
+---
+
+## Task 1: Extend WorktreeRunner with fetch / remote / tracking primitives
+
+Adds the git primitives and types with no behavior change to existing flows. Existing tests must still compile and pass (so `mockGit` gains the new methods returning zero values).
+
+**Files:**
+- Modify: `internal/worktree/worktree.go`
+- Test: `internal/worktree/worktree_test.go`
+
+**Interfaces:**
+- Produces:
+ - `type RemoteBranch struct { Remote, Branch, Ref string }`
+ - `NewResult` gains field `Branch string`
+ - `var ErrLocalBranchExists = errors.New("local branch already exists")`
+ - `WorktreeRunner` methods: `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)`
+ - pure func `parseRemoteBranches(output string) []RemoteBranch`
+
+- [ ] **Step 1: Write the failing test for `parseRemoteBranches`**
+
+Add to `internal/worktree/worktree_test.go`:
+
+```go
+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])
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test ./internal/worktree/ -run TestParseRemoteBranches`
+Expected: FAIL — `undefined: parseRemoteBranches`.
+
+- [ ] **Step 3: Add types, sentinel, interface methods, real implementations, and `parseRemoteBranches`**
+
+In `internal/worktree/worktree.go`, add to the sentinel `var (...)` block (around line 20):
+
+```go
+ ErrLocalBranchExists = errors.New("local branch already exists")
+```
+
+Add the `Branch` field to `NewResult` (around line 42):
+
+```go
+// NewResult is the result of a successful worktree creation.
+type NewResult struct {
+ Path string
+ Branch string // resolved local branch name (derived for --track)
+}
+```
+
+Add the `RemoteBranch` type near `WorktreeInfo`:
+
+```go
+// RemoteBranch is one remote-tracking branch (e.g. origin/feature-x).
+type RemoteBranch struct {
+ Remote string
+ Branch string
+ Ref string // "/"
+}
+```
+
+Extend the `WorktreeRunner` interface (replace the block at lines 47-53):
+
+```go
+// 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)
+}
+```
+
+Add the real implementations after `List` (after line 109) and the parser after `parseWorktreePorcelain`:
+
+```go
+func (r *RealWorktreeRunner) 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 *RealWorktreeRunner) 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 *RealWorktreeRunner) FastForward(cloneDir, remote, branch string) error {
+ cmd := exec.Command("git", "fetch", remote, branch+":"+branch)
+ cmd.Dir = cloneDir
+ if _, err := cmd.CombinedOutput(); err == nil {
+ return nil
+ }
+ cmd = exec.Command("git", "merge", "--ff-only", remote+"/"+branch)
+ 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 *RealWorktreeRunner) 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 *RealWorktreeRunner) 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 *RealWorktreeRunner) 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 *RealWorktreeRunner) HasLocalBranch(cloneDir, branch string) (bool, error) {
+ cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
+ 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, err
+}
+
+// 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
+}
+```
+
+- [ ] **Step 4: Extend `mockGit` so existing tests compile**
+
+In `internal/worktree/worktree_test.go`, replace the `mockGit` struct (lines 111-122) and add the new methods after `List` (after line 140):
+
+```go
+// mockGit records calls and controls return values.
+type mockGit struct {
+ addErr error
+ addNewErr error
+ addNewFromErr error
+ addNewFromCalled bool
+ addNewFromStartPoint string
+ removeErr error
+ listResult []WorktreeInfo
+ listErr error
+ addCalled bool
+ addNewCalled bool
+
+ fetchErr error
+ fetchCalls [][2]string // {remote, branch}
+ fetchAllErr error
+ fetchAllCalled bool
+ fastForwardErr error
+ fastForwardCalls [][2]string
+ remotesResult []string
+ remotesErr error
+ listRemoteResult []RemoteBranch
+ listRemoteErr error
+ addTrackingErr error
+ addTrackingCalled bool
+ addTrackingBranch string
+ addTrackingRef string
+ hasLocalBranch bool
+ hasLocalBranchErr error
+}
+```
+
+```go
+func (m *mockGit) Fetch(cloneDir, remote, branch string) error {
+ m.fetchCalls = append(m.fetchCalls, [2]string{remote, branch})
+ return m.fetchErr
+}
+func (m *mockGit) FetchAll(cloneDir string) error {
+ m.fetchAllCalled = true
+ return m.fetchAllErr
+}
+func (m *mockGit) FastForward(cloneDir, remote, branch string) error {
+ m.fastForwardCalls = append(m.fastForwardCalls, [2]string{remote, branch})
+ return m.fastForwardErr
+}
+func (m *mockGit) Remotes(cloneDir string) ([]string, error) {
+ return m.remotesResult, m.remotesErr
+}
+func (m *mockGit) ListRemoteBranches(cloneDir string) ([]RemoteBranch, error) {
+ return m.listRemoteResult, m.listRemoteErr
+}
+func (m *mockGit) AddTracking(cloneDir, worktreePath, branch, remoteRef string) error {
+ m.addTrackingCalled = true
+ m.addTrackingBranch = branch
+ m.addTrackingRef = remoteRef
+ return m.addTrackingErr
+}
+func (m *mockGit) HasLocalBranch(cloneDir, branch string) (bool, error) {
+ return m.hasLocalBranch, m.hasLocalBranchErr
+}
+```
+
+- [ ] **Step 5: Run tests to verify the new parser passes and nothing else broke**
+
+Run: `go test ./internal/worktree/`
+Expected: PASS (existing tests unchanged this task; `TestParseRemoteBranches` passes).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/worktree/worktree.go internal/worktree/worktree_test.go
+git commit -m "feat(worktree): add fetch/remote/tracking primitives to WorktreeRunner"
+```
+
+---
+
+## Task 2: Pure ref parsing — `parseRef`
+
+**Files:**
+- Modify: `internal/worktree/worktree.go`
+- Test: `internal/worktree/worktree_test.go`
+
+**Interfaces:**
+- Produces: `func parseRef(remotes []string, ref string) (remote, branch string, explicit bool)`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+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)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test ./internal/worktree/ -run TestParseRef`
+Expected: FAIL — `undefined: parseRef`.
+
+- [ ] **Step 3: Implement `parseRef`**
+
+Add to `internal/worktree/worktree.go`:
+
+```go
+// 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
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/worktree/ -run TestParseRef`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/worktree/worktree.go internal/worktree/worktree_test.go
+git commit -m "feat(worktree): add parseRef ref-splitting helper"
+```
+
+---
+
+## Task 3: Fresh source on creation — `freshenStartPoint` + rewire `New`/`NewFrom`
+
+Adds the freshness helper and routes both existing creation paths through it. This **changes behavior** of `New` (create-new fallback) and `NewFrom`, so two existing tests are updated.
+
+**Files:**
+- Modify: `internal/worktree/worktree.go`
+- Test: `internal/worktree/worktree_test.go`
+
+**Interfaces:**
+- Consumes: `parseRef` (Task 2); `WorktreeRunner.Fetch/FastForward/Remotes/HasLocalBranch` (Task 1).
+- Produces: `func (s *Service) freshenStartPoint(cloneDir, src string) string`; `New`/`NewFrom` now populate `NewResult.Branch` and base new branches off the freshened start point.
+
+- [ ] **Step 1: Write failing tests for the helper and the rewired paths**
+
+Add new tests and **replace** the two existing tests noted below.
+
+New helper tests:
+
+```go
+func TestFreshenStartPoint_localBranchPreferred(t *testing.T) {
+ git := &mockGit{hasLocalBranch: true}
+ svc, _ := makeService(t, git, &mockTmuxRunner{})
+ got := svc.freshenStartPoint("/clone", "main")
+ if got != "main" {
+ t.Errorf("start point = %q, want %q", got, "main")
+ }
+ if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "main"} {
+ t.Errorf("fetch calls = %v", git.fetchCalls)
+ }
+ if len(git.fastForwardCalls) != 1 || git.fastForwardCalls[0] != [2]string{"origin", "main"} {
+ t.Errorf("fast-forward calls = %v", git.fastForwardCalls)
+ }
+}
+
+func TestFreshenStartPoint_noLocalBranchUsesRemoteTracking(t *testing.T) {
+ git := &mockGit{hasLocalBranch: false}
+ svc, _ := makeService(t, git, &mockTmuxRunner{})
+ got := svc.freshenStartPoint("/clone", "feat-x")
+ if got != "origin/feat-x" {
+ t.Errorf("start point = %q, want %q", got, "origin/feat-x")
+ }
+ if len(git.fastForwardCalls) != 0 {
+ t.Errorf("did not expect fast-forward, got %v", git.fastForwardCalls)
+ }
+}
+
+func TestFreshenStartPoint_fetchFailsFallsBackToRaw(t *testing.T) {
+ git := &mockGit{fetchErr: fmt.Errorf("no such branch")}
+ svc, _ := makeService(t, git, &mockTmuxRunner{})
+ got := svc.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) {
+ git := &mockGit{remotesResult: []string{"origin", "upstream"}}
+ svc, _ := makeService(t, git, &mockTmuxRunner{})
+ got := svc.freshenStartPoint("/clone", "upstream/feat-x")
+ if got != "upstream/feat-x" {
+ t.Errorf("start point = %q, want %q", got, "upstream/feat-x")
+ }
+ if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"upstream", "feat-x"} {
+ t.Errorf("fetch calls = %v", git.fetchCalls)
+ }
+}
+```
+
+**Replace** `TestService_New_branchNotFound_fallsBackToAddNew` (lines 227-241) with:
+
+```go
+func TestService_New_branchNotFound_createsFromFreshDefault(t *testing.T) {
+ git := &mockGit{addErr: fmt.Errorf("invalid reference"), hasLocalBranch: false}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := svc.New("myapp", "new-feature")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !git.addNewFromCalled {
+ t.Error("expected AddNewBranchFrom to be called on Add failure")
+ }
+ if git.addNewFromStartPoint != "origin/main" {
+ t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "origin/main")
+ }
+ if result.Branch != "new-feature" {
+ t.Errorf("branch = %q, want %q", result.Branch, "new-feature")
+ }
+}
+```
+
+**Replace** `TestService_New_withFromBranch` (lines 243-264) with:
+
+```go
+func TestService_New_withFromBranch(t *testing.T) {
+ git := &mockGit{hasLocalBranch: false}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := svc.NewFrom("myapp", "my-feature", "feature-auth")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !git.addNewFromCalled {
+ t.Error("expected AddNewBranchFrom to be called")
+ }
+ if git.addNewFromStartPoint != "origin/feature-auth" {
+ t.Errorf("start point = %q, want %q", git.addNewFromStartPoint, "origin/feature-auth")
+ }
+ if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "feature-auth"} {
+ t.Errorf("fetch calls = %v", git.fetchCalls)
+ }
+ expectedPath := cloneDirPath(tmpDir) + "__worktrees/my-feature"
+ if result.Path != expectedPath {
+ t.Errorf("path = %q, want %q", result.Path, expectedPath)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failures**
+
+Run: `go test ./internal/worktree/ -run 'TestFreshenStartPoint|TestService_New_branchNotFound_createsFromFreshDefault|TestService_New_withFromBranch'`
+Expected: FAIL — `freshenStartPoint` undefined and start-point/`Branch` assertions unmet.
+
+- [ ] **Step 3: Implement `freshenStartPoint` and rewire `New`/`NewFrom`**
+
+Add the helper to `internal/worktree/worktree.go`:
+
+```go
+// freshenStartPoint fetches updates for the source ref and returns the start
+// point a new worktree 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 (s *Service) freshenStartPoint(cloneDir, src string) string {
+ remotes, _ := s.git.Remotes(cloneDir)
+ remote, branch, explicit := parseRef(remotes, src)
+ if explicit {
+ _ = s.git.Fetch(cloneDir, remote, branch)
+ return src
+ }
+ if err := s.git.Fetch(cloneDir, "origin", src); err != nil {
+ return src
+ }
+ if has, _ := s.git.HasLocalBranch(cloneDir, src); has {
+ _ = s.git.FastForward(cloneDir, "origin", src)
+ return src
+ }
+ return "origin/" + src
+}
+```
+
+In `New` (lines 203-214), replace the create block and the return:
+
+```go
+ addErr := s.git.Add(cloneDir, worktreePath, branch)
+ if addErr != nil {
+ src := p.DefaultBranch
+ if src == "" {
+ src = "main"
+ }
+ startPoint := s.freshenStartPoint(cloneDir, src)
+ if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint); err != nil {
+ return NewResult{}, fmt.Errorf("failed to create worktree (add: %v; add -b from %s: %w)", addErr, startPoint, err)
+ }
+ }
+
+ if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil {
+ return NewResult{}, fmt.Errorf("post-worktree hook: %w", err)
+ }
+
+ return NewResult{Path: worktreePath, Branch: branch}, nil
+```
+
+In `NewFrom` (lines 253-261), replace the add + return:
+
+```go
+ startPoint := s.freshenStartPoint(cloneDir, fromBranch)
+ if err := s.git.AddNewBranchFrom(cloneDir, worktreePath, branch, startPoint); err != nil {
+ return NewResult{}, fmt.Errorf("creating worktree from %s: %w", startPoint, err)
+ }
+
+ if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil {
+ return NewResult{}, fmt.Errorf("post-worktree hook: %w", err)
+ }
+
+ return NewResult{Path: worktreePath, Branch: branch}, nil
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/worktree/`
+Expected: PASS (all worktree tests, including the rewritten ones).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/worktree/worktree.go internal/worktree/worktree_test.go
+git commit -m "feat(worktree): base new worktrees on a freshly-fetched source"
+```
+
+---
+
+## Task 4: Service methods — `NewTracking`, `ListRemoteBranches`, `RemoteBranches`
+
+**Files:**
+- Modify: `internal/worktree/worktree.go`
+- Test: `internal/worktree/worktree_test.go`
+
+**Interfaces:**
+- Consumes: `parseRef`, `WorktreeRunner.{Remotes,Fetch,HasLocalBranch,AddTracking,FetchAll,ListRemoteBranches}`, `resolvePaths`.
+- Produces:
+ - `func (s *Service) NewTracking(project, branch, ref string) (NewResult, error)`
+ - `func (s *Service) ListRemoteBranches(project string) ([]RemoteBranch, error)`
+ - `func (s *Service) RemoteBranches(project string) ([]RemoteBranch, error)`
+
+- [ ] **Step 1: Write failing tests**
+
+```go
+func TestService_NewTracking_success(t *testing.T) {
+ git := &mockGit{remotesResult: []string{"origin"}}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := svc.NewTracking("myapp", "", "feat-x")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(git.fetchCalls) != 1 || git.fetchCalls[0] != [2]string{"origin", "feat-x"} {
+ t.Errorf("fetch calls = %v", git.fetchCalls)
+ }
+ if !git.addTrackingCalled || git.addTrackingBranch != "feat-x" || git.addTrackingRef != "origin/feat-x" {
+ t.Errorf("AddTracking branch=%q ref=%q", git.addTrackingBranch, git.addTrackingRef)
+ }
+ if result.Branch != "feat-x" {
+ t.Errorf("branch = %q, want feat-x", result.Branch)
+ }
+ expectedPath := cloneDirPath(tmpDir) + "__worktrees/feat-x"
+ if result.Path != expectedPath {
+ t.Errorf("path = %q, want %q", result.Path, expectedPath)
+ }
+}
+
+func TestService_NewTracking_overrideName(t *testing.T) {
+ git := &mockGit{remotesResult: []string{"origin", "upstream"}}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := svc.NewTracking("myapp", "review", "upstream/feat-x")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if git.addTrackingBranch != "review" || git.addTrackingRef != "upstream/feat-x" {
+ t.Errorf("AddTracking branch=%q ref=%q", git.addTrackingBranch, git.addTrackingRef)
+ }
+ if result.Branch != "review" {
+ t.Errorf("branch = %q, want review", result.Branch)
+ }
+}
+
+func TestService_NewTracking_localBranchExists(t *testing.T) {
+ git := &mockGit{remotesResult: []string{"origin"}, hasLocalBranch: true}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := svc.NewTracking("myapp", "", "feat-x")
+ if !errors.Is(err, ErrLocalBranchExists) {
+ t.Errorf("expected ErrLocalBranchExists, got %v", err)
+ }
+ if git.addTrackingCalled {
+ t.Error("AddTracking should not be called when the branch already exists")
+ }
+}
+
+func TestService_NewTracking_fetchFails(t *testing.T) {
+ git := &mockGit{remotesResult: []string{"origin"}, fetchErr: fmt.Errorf("no such ref")}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := svc.NewTracking("myapp", "", "feat-x")
+ if err == nil {
+ t.Fatal("expected error when fetch fails")
+ }
+ if git.addTrackingCalled {
+ t.Error("AddTracking should not run after a failed fetch")
+ }
+}
+
+func TestService_NewTracking_notCloned(t *testing.T) {
+ svc, _ := makeService(t, &mockGit{remotesResult: []string{"origin"}}, &mockTmuxRunner{})
+ _, err := svc.NewTracking("myapp", "", "feat-x")
+ if !errors.Is(err, ErrNotCloned) {
+ t.Errorf("expected ErrNotCloned, got %v", err)
+ }
+}
+
+func TestService_RemoteBranches_fetchesThenLists(t *testing.T) {
+ git := &mockGit{listRemoteResult: []RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := svc.RemoteBranches("myapp")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !git.fetchAllCalled {
+ t.Error("expected FetchAll to be called")
+ }
+ if len(got) != 1 || got[0].Ref != "origin/feat-x" {
+ t.Errorf("branches = %+v", got)
+ }
+}
+
+func TestService_ListRemoteBranches_noFetch(t *testing.T) {
+ git := &mockGit{listRemoteResult: []RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}}}
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{})
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := svc.ListRemoteBranches("myapp")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if git.fetchAllCalled {
+ t.Error("ListRemoteBranches must not fetch")
+ }
+ if len(got) != 1 {
+ t.Errorf("branches = %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failures**
+
+Run: `go test ./internal/worktree/ -run 'NewTracking|RemoteBranches'`
+Expected: FAIL — methods undefined.
+
+- [ ] **Step 3: Implement the three Service methods**
+
+Add a small clone-dir helper and the methods to `internal/worktree/worktree.go`:
+
+```go
+// cloneDirFor returns the clone directory for a project without needing a
+// branch (unlike resolvePaths, which also computes a worktree path).
+func (s *Service) cloneDirFor(project string) (string, error) {
+ p, ok := s.cfg.Projects[project]
+ if !ok {
+ return "", fmt.Errorf("project %q is not configured", project)
+ }
+ repoPath, err := config.RepoPath(p.Repo)
+ if err != nil {
+ return "", fmt.Errorf("parsing repo URL: %w", err)
+ }
+ return semconv.CloneDir(s.cfg.Defaults.ProjectsDir, repoPath), nil
+}
+
+// NewTracking fetches a remote branch and creates a worktree whose local branch
+// tracks it. ref is "[/]" (default remote origin). When branch
+// is empty the local name is derived from the remote branch; otherwise it
+// overrides the derived name.
+func (s *Service) NewTracking(project, branch, ref string) (NewResult, error) {
+ p, ok := s.cfg.Projects[project]
+ if !ok {
+ return NewResult{}, fmt.Errorf("project %q is not configured", project)
+ }
+
+ cloneDir, err := s.cloneDirFor(project)
+ if err != nil {
+ return NewResult{}, err
+ }
+ if _, err := os.Stat(cloneDir); os.IsNotExist(err) {
+ return NewResult{}, fmt.Errorf("%w: %s", ErrNotCloned, project)
+ }
+
+ remotes, _ := s.git.Remotes(cloneDir)
+ remote, remoteBranch, _ := parseRef(remotes, ref)
+ localName := branch
+ if localName == "" {
+ localName = remoteBranch
+ }
+ remoteRef := remote + "/" + remoteBranch
+
+ _, worktreesRoot, worktreePath, err := s.resolvePaths(project, localName)
+ if err != nil {
+ return NewResult{}, err
+ }
+ if _, err := os.Stat(worktreePath); err == nil {
+ return NewResult{}, fmt.Errorf("%w: %s/%s", ErrWorktreeExists, project, localName)
+ }
+
+ if has, _ := s.git.HasLocalBranch(cloneDir, localName); has {
+ return NewResult{}, fmt.Errorf("%w: %s", ErrLocalBranchExists, localName)
+ }
+
+ if err := s.git.Fetch(cloneDir, remote, remoteBranch); err != nil {
+ return NewResult{}, fmt.Errorf("fetching %s: %w", remoteRef, err)
+ }
+
+ attrs := map[string]string{
+ semconv.HookAttrProject: project,
+ semconv.HookAttrBranch: localName,
+ semconv.HookAttrRepo: p.Repo,
+ semconv.HookAttrCloneDir: cloneDir,
+ semconv.HookAttrWorktreePath: worktreePath,
+ }
+
+ if err := s.hook.Trigger(semconv.HookPreWorktree, attrs, worktreePath); err != nil {
+ return NewResult{}, fmt.Errorf("pre-worktree hook: %w", err)
+ }
+
+ if err := os.MkdirAll(worktreesRoot, 0o755); err != nil {
+ return NewResult{}, fmt.Errorf("creating worktrees dir: %w", err)
+ }
+
+ if err := s.git.AddTracking(cloneDir, worktreePath, localName, remoteRef); err != nil {
+ return NewResult{}, fmt.Errorf("creating tracking worktree for %s: %w", remoteRef, err)
+ }
+
+ if err := s.hook.Trigger(semconv.HookPostWorktree, attrs, worktreePath); err != nil {
+ return NewResult{}, fmt.Errorf("post-worktree hook: %w", err)
+ }
+
+ return NewResult{Path: worktreePath, Branch: localName}, nil
+}
+
+// ListRemoteBranches returns the remote-tracking branches for a project without
+// fetching (suitable for shell completion).
+func (s *Service) ListRemoteBranches(project string) ([]RemoteBranch, error) {
+ cloneDir, err := s.cloneDirFor(project)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := os.Stat(cloneDir); os.IsNotExist(err) {
+ return nil, fmt.Errorf("%w: %s", ErrNotCloned, project)
+ }
+ return s.git.ListRemoteBranches(cloneDir)
+}
+
+// RemoteBranches fetches all remotes (best-effort) then lists remote-tracking
+// branches — used by the TUI picker so the list reflects current remote state.
+func (s *Service) RemoteBranches(project string) ([]RemoteBranch, error) {
+ cloneDir, err := s.cloneDirFor(project)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := os.Stat(cloneDir); os.IsNotExist(err) {
+ return nil, fmt.Errorf("%w: %s", ErrNotCloned, project)
+ }
+ _ = s.git.FetchAll(cloneDir)
+ return s.git.ListRemoteBranches(cloneDir)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/worktree/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/worktree/worktree.go internal/worktree/worktree_test.go
+git commit -m "feat(worktree): add NewTracking and remote-branch listing"
+```
+
+---
+
+## Task 5: CLI `--track` flag on `create worktree`
+
+**Files:**
+- Modify: `cmd/worktree.go`
+- Test: `cmd/worktree_test.go` (create if absent; otherwise add to the existing cmd test file)
+
+**Interfaces:**
+- Consumes: `worktree.Service.NewTracking`, `worktree.NewResult.Branch` (Task 4).
+- Produces: `CreateWorktreeCmd.Track` field; `--track` flag; `create worktree` accepting 1-2 positionals.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `cmd/worktree_test.go` (package `cmd`). These tests exercise the Cobra wiring without hitting git.
+
+```go
+package cmd
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCreateWorktree_trackFlagRegistered(t *testing.T) {
+ c := (&CreateWorktreeCmd{}).Cobra()
+ if c.Flags().Lookup("track") == nil {
+ t.Fatal("expected --track flag to be registered")
+ }
+}
+
+func TestCreateWorktree_trackAndFromMutuallyExclusive(t *testing.T) {
+ c := (&CreateWorktreeCmd{}).Cobra()
+ c.SetArgs([]string{"myapp", "--track", "feat-x", "--from", "main"})
+ c.SilenceUsage = true
+ c.SilenceErrors = true
+ err := c.Execute()
+ if err == nil || !strings.Contains(err.Error(), "exclusive") {
+ t.Fatalf("expected mutual-exclusion error, got %v", err)
+ }
+}
+
+func TestCreateWorktree_branchRequiredWithoutTrack(t *testing.T) {
+ c := (&CreateWorktreeCmd{}).Cobra()
+ c.SetArgs([]string{"myapp"})
+ c.SilenceUsage = true
+ c.SilenceErrors = true
+ err := c.Execute()
+ if err == nil {
+ t.Fatal("expected error when no branch and no --track given")
+ }
+}
+```
+
+Note: `TestCreateWorktree_branchRequiredWithoutTrack` reaches `Run`, which reads the package `cfg` global. If `cfg` is nil in the cmd test package, guard by setting a minimal `cfg` in the test, mirroring how sibling cmd tests set it up (check existing `cmd` tests for the pattern, e.g. a `setTestConfig` helper or direct `cfg = &config.Config{...}` assignment). Use the same approach already present in the package.
+
+- [ ] **Step 2: Run to verify failures**
+
+Run: `go test ./cmd/ -run TestCreateWorktree`
+Expected: FAIL — `track` flag missing / no mutual exclusion / branch not required.
+
+- [ ] **Step 3: Implement the flag, args, and routing**
+
+In `cmd/worktree.go`, update the struct (lines 67-71):
+
+```go
+type CreateWorktreeCmd struct {
+ From string
+ Track string
+ Attach bool
+ Agent string
+}
+```
+
+Update `Cobra()` (lines 73-88):
+
+```go
+func (c *CreateWorktreeCmd) Cobra() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "worktree [branch]",
+ Aliases: []string{"worktrees", "wt"},
+ Short: "Create a new worktree for a project",
+ Args: cobra.RangeArgs(1, 2),
+ RunE: c.Run,
+ ValidArgsFunction: completeProjectOnly,
+ }
+ cmd.Flags().StringVar(&c.From, "from", "", "base branch to create worktree from")
+ cmd.Flags().StringVar(&c.Track, "track", "", "remote branch to fetch and check out (e.g. feat-x or upstream/feat-x)")
+ cmd.Flags().BoolVar(&c.Attach, "attach", false, "start a coding session after creation")
+ cmd.Flags().StringVar(&c.Agent, "agent", "", "agent to use for the session (with --attach)")
+ cmd.MarkFlagsMutuallyExclusive("from", "track")
+ _ = cmd.RegisterFlagCompletionFunc("from", completeBranches)
+ _ = cmd.RegisterFlagCompletionFunc("track", completeRemoteBranches)
+ _ = cmd.RegisterFlagCompletionFunc("agent", completeAgents)
+ return cmd
+}
+```
+
+Replace the top of `Run` (lines 90-113) so it handles the optional positional and `--track`, and uses `result.Branch` afterward:
+
+```go
+func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
+ project := args[0]
+ posBranch := ""
+ if len(args) > 1 {
+ posBranch = args[1]
+ }
+ if c.Track == "" && posBranch == "" {
+ 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)
+ }
+ if err != nil {
+ fmt.Fprintln(cmd.OutOrStdout())
+ return worktreeErr(cmd, project, posBranch, err)
+ }
+
+ branch := result.Branch
+```
+
+The remaining body (file copy, template processing, "done"/Path print, and the `--attach` block) is unchanged and already references the local `branch` variable — it now carries the resolved (possibly derived) name. Verify no other reference to the old positional `branch` remains in the function.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./cmd/ -run TestCreateWorktree`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cmd/worktree.go cmd/worktree_test.go
+git commit -m "feat(cmd): add --track to create worktree for remote checkout"
+```
+
+---
+
+## Task 6: Shell completion for `--track`
+
+**Files:**
+- Modify: `cmd/completion.go`
+- Test: `cmd/completion_test.go`
+
+**Interfaces:**
+- Consumes: `worktree.Service.ListRemoteBranches`, `worktree.RemoteBranch` (Task 4).
+- Produces: `var completionRemoteBrancher func(*config.Config, string) ([]worktree.RemoteBranch, error)`; `func completeRemoteBranches(...)` (referenced by Task 5).
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `cmd/completion_test.go`:
+
+```go
+func TestCompleteRemoteBranches(t *testing.T) {
+ orig := completionRemoteBrancher
+ t.Cleanup(func() { completionRemoteBrancher = orig })
+ completionRemoteBrancher = func(_ *config.Config, project string) ([]worktree.RemoteBranch, error) {
+ return []worktree.RemoteBranch{
+ {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"},
+ {Remote: "upstream", Branch: "fix-y", Ref: "upstream/fix-y"},
+ }, nil
+ }
+
+ cmd := (&CreateWorktreeCmd{}).Cobra()
+ got, directive := completeRemoteBranches(cmd, []string{"myapp"}, "")
+ if directive != cobra.ShellCompDirectiveNoFileComp {
+ t.Errorf("directive = %v", directive)
+ }
+ want := []string{"origin/feat-x", "upstream/fix-y"}
+ if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ t.Errorf("completions = %v, want %v", got, want)
+ }
+}
+
+func TestCompleteRemoteBranches_noProject(t *testing.T) {
+ cmd := (&CreateWorktreeCmd{}).Cobra()
+ got, _ := completeRemoteBranches(cmd, nil, "")
+ if got != nil {
+ t.Errorf("expected nil completions with no project, got %v", got)
+ }
+}
+```
+
+Match the import list to the existing `cmd/completion_test.go` (it already imports `cobra`, `config`, and `worktree` for sibling tests; add any missing ones).
+
+- [ ] **Step 2: Run to verify failures**
+
+Run: `go test ./cmd/ -run TestCompleteRemoteBranches`
+Expected: FAIL — `completionRemoteBrancher`/`completeRemoteBranches` undefined.
+
+- [ ] **Step 3: Implement the completion**
+
+Add to `cmd/completion.go` (the file already imports `sort`, `cobra`, `config`, `hooks`, `tmux`, `worktree`):
+
+```go
+// 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)
+}
+
+// completeRemoteBranches completes the --track flag against the remote-tracking
+// branches of the project named in args[0].
+func completeRemoteBranches(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
+ if len(args) == 0 {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+ branches, err := completionRemoteBrancher(loadCompletionConfig(cmd), args[0])
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+ refs := make([]string, 0, len(branches))
+ for _, b := range branches {
+ refs = append(refs, b.Ref)
+ }
+ sort.Strings(refs)
+ return refs, cobra.ShellCompDirectiveNoFileComp
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./cmd/ -run TestCompleteRemoteBranches`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cmd/completion.go cmd/completion_test.go
+git commit -m "feat(cmd): complete --track against remote-tracking branches"
+```
+
+---
+
+## Task 7: TUI remote-branch picker
+
+Rebinds `r` from manual refresh to the remote-branch picker, and adds the picker model/screen and the async fetch+list command. Selecting a branch transitions to the create-worktree form (wired in Task 8; this task wires it to a temporary handoff and tests the picker in isolation).
+
+**Files:**
+- Modify: `internal/tui/keys.go`, `internal/tui/model.go`
+- Create: `internal/tui/remote_picker.go`, `internal/tui/remote_picker_test.go`
+
+**Interfaces:**
+- Consumes: `worktree.Service.RemoteBranches`, `worktree.RemoteBranch` (Task 4).
+- Produces: `screenRemotePicker` const; `remoteBranchesMsg` type; `Model.remotePicker` field; `remotePickerModel` with `setBranches`/`selected`; `Model.startRemotePicker`/`fetchRemoteBranchesCmd`/`updateRemotePicker`/`showTrackForm`; `keyMap.Remote`.
+
+- [ ] **Step 1: Write failing tests for the picker model**
+
+Create `internal/tui/remote_picker_test.go`:
+
+```go
+package tui
+
+import (
+ "testing"
+
+ "github.com/xico42/codeherd/internal/config"
+ "github.com/xico42/codeherd/internal/worktree"
+)
+
+func TestRemotePicker_setAndSelect(t *testing.T) {
+ p := newRemotePicker("myapp", &config.Config{}, nil)
+ if !p.loading {
+ t.Error("picker should start in loading state")
+ }
+ p.setBranches([]worktree.RemoteBranch{
+ {Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"},
+ {Remote: "origin", Branch: "fix-y", Ref: "origin/fix-y"},
+ })
+ if p.loading {
+ t.Error("picker should leave loading state after setBranches")
+ }
+ rb, ok := p.selected()
+ if !ok || rb.Ref != "origin/feat-x" {
+ t.Errorf("selected = %+v ok=%v, want origin/feat-x", rb, ok)
+ }
+}
+
+func TestRemotePicker_emptySelectNone(t *testing.T) {
+ p := newRemotePicker("myapp", &config.Config{}, nil)
+ p.setBranches(nil)
+ if _, ok := p.selected(); ok {
+ t.Error("expected no selection on empty picker")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failures**
+
+Run: `go test ./internal/tui/ -run TestRemotePicker`
+Expected: FAIL — `newRemotePicker` undefined.
+
+- [ ] **Step 3: Create the picker model**
+
+Create `internal/tui/remote_picker.go`:
+
+```go
+package tui
+
+import (
+ "charm.land/bubbles/v2/list"
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/xico42/codeherd/internal/config"
+ "github.com/xico42/codeherd/internal/tmux"
+ "github.com/xico42/codeherd/internal/worktree"
+)
+
+// remoteBranchItem adapts a RemoteBranch to the bubbles list.Item interface.
+type remoteBranchItem struct {
+ rb worktree.RemoteBranch
+}
+
+func (i remoteBranchItem) FilterValue() string { return i.rb.Ref }
+func (i remoteBranchItem) Title() string { return i.rb.Ref }
+func (i remoteBranchItem) Description() string { return "" }
+
+// remotePickerModel shows a filterable list of remote-tracking branches.
+type remotePickerModel struct {
+ list list.Model
+ project string
+ loading bool
+ errText string
+ cfg *config.Config
+ tmuxClient *tmux.Client
+}
+
+func newRemotePicker(project string, cfg *config.Config, tmuxClient *tmux.Client) *remotePickerModel {
+ l := list.New(nil, list.NewDefaultDelegate(), maxWidth, 20)
+ l.Title = "Remote branches"
+ l.SetShowHelp(false)
+ l.SetFilteringEnabled(true)
+ l.DisableQuitKeybindings()
+ return &remotePickerModel{
+ list: l,
+ project: project,
+ loading: true,
+ cfg: cfg,
+ tmuxClient: tmuxClient,
+ }
+}
+
+func (p *remotePickerModel) setBranches(branches []worktree.RemoteBranch) {
+ items := make([]list.Item, len(branches))
+ for i, b := range branches {
+ items[i] = remoteBranchItem{rb: b}
+ }
+ p.list.SetItems(items)
+ p.loading = false
+}
+
+func (p *remotePickerModel) selected() (worktree.RemoteBranch, bool) {
+ it, ok := p.list.SelectedItem().(remoteBranchItem)
+ if !ok {
+ return worktree.RemoteBranch{}, false
+ }
+ return it.rb, true
+}
+
+func (p *remotePickerModel) Update(msg tea.Msg) (*remotePickerModel, tea.Cmd) {
+ var cmd tea.Cmd
+ p.list, cmd = p.list.Update(msg)
+ return p, cmd
+}
+
+func (p *remotePickerModel) View() string {
+ switch {
+ case p.loading:
+ return "Fetching remote branches…"
+ case p.errText != "":
+ return "Error: " + p.errText + "\n\nEsc: cancel"
+ case len(p.list.Items()) == 0:
+ return "No remote branches found (try again after pushing/fetching).\n\nEsc: cancel"
+ default:
+ return p.list.View()
+ }
+}
+```
+
+- [ ] **Step 4: Run picker model tests to verify they pass**
+
+Run: `go test ./internal/tui/ -run TestRemotePicker`
+Expected: PASS.
+
+- [ ] **Step 5: Write failing test for the key binding and message handling**
+
+Add to `internal/tui/remote_picker_test.go`:
+
+```go
+func TestRemoteBranchesMsg_populatesPicker(t *testing.T) {
+ m := Model{screen: screenRemotePicker, remotePicker: newRemotePicker("myapp", &config.Config{}, nil)}
+ updated, _ := m.Update(remoteBranchesMsg{
+ project: "myapp",
+ branches: []worktree.RemoteBranch{{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"}},
+ })
+ mm := updated.(Model)
+ if mm.remotePicker.loading {
+ t.Error("expected picker to leave loading after branches arrive")
+ }
+ if got := len(mm.remotePicker.list.Items()); got != 1 {
+ t.Errorf("items = %d, want 1", got)
+ }
+}
+
+func TestRemoteKeyBindingPresent(t *testing.T) {
+ k := defaultKeyMap()
+ if len(k.Remote.Keys()) == 0 {
+ t.Fatal("expected Remote binding to have keys")
+ }
+ if k.Remote.Keys()[0] != "r" {
+ t.Errorf("Remote key = %q, want r", k.Remote.Keys()[0])
+ }
+}
+
+func TestRefreshKeyBindingRemoved(t *testing.T) {
+ // 'r' now opens the remote picker; manual refresh is gone (auto-refresh
+ // covers it). The keyMap must no longer carry a Refresh binding — this test
+ // fails to compile if the field is reintroduced, which is the intent.
+ k := defaultKeyMap()
+ for _, b := range k.FullHelp() {
+ for _, kb := range b {
+ for _, key := range kb.Keys() {
+ if key == "r" && kb.Help().Desc == "refresh" {
+ t.Error("manual refresh binding should be removed")
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Run to verify failures**
+
+Run: `go test ./internal/tui/ -run 'TestRemoteBranchesMsg|TestRemoteKeyBindingPresent'`
+Expected: FAIL — `screenRemotePicker`/`remoteBranchesMsg`/`keyMap.Remote` undefined.
+
+- [ ] **Step 7: Wire the binding, screen, message, and handlers**
+
+In `internal/tui/keys.go`, **remove** the `Refresh` field from `keyMap` (line 11) and **add** the `Remote` field in its place:
+
+```go
+ Remote key.Binding
+```
+
+**Remove** the `Refresh` binding from `defaultKeyMap()` (lines 40-43) and **add** the `Remote` binding (after the `New` binding):
+
+```go
+ Remote: key.NewBinding(
+ key.WithKeys("r"),
+ key.WithHelp("r", "remote"),
+ ),
+```
+
+In `FullHelp()`, replace `k.Refresh` with `k.Remote` in the second row (line 70):
+
+```go
+ {k.New, k.Remote, k.Delete},
+```
+
+Note: `refreshCmd()` (the data refresh method) stays — it is still driven by the 3-second `tickCmd` and other callers. Only the manual-refresh *key binding* is removed.
+
+In `internal/tui/model.go`:
+
+Add the screen const (in the `const (...)` block at line 24):
+
+```go
+ screenRemotePicker
+```
+
+Add the message type (near the other message types, ~line 39):
+
+```go
+type remoteBranchesMsg struct {
+ project string
+ branches []worktree.RemoteBranch
+ err error
+}
+```
+
+Add the field to `Model` (near `agentPicker`, ~line 80):
+
+```go
+ // Remote-branch picker sub-model.
+ remotePicker *remotePickerModel
+```
+
+Handle the message in the top-level `Update` switch (add a case alongside the others, before the `// Route to sub-screens.` block):
+
+```go
+ case remoteBranchesMsg:
+ if m.remotePicker != nil && m.remotePicker.project == msg.project {
+ if msg.err != nil {
+ m.remotePicker.errText = msg.err.Error()
+ m.remotePicker.loading = false
+ } else {
+ m.remotePicker.setBranches(msg.branches)
+ }
+ }
+ return m, nil
+```
+
+Add routing in the screen switch (in `Update`, the `switch m.screen` block):
+
+```go
+ case screenRemotePicker:
+ return m.updateRemotePicker(msg)
+```
+
+In `updateList`, **replace** the existing `Refresh` dispatch case (lines 276-277) with the `Remote` dispatch:
+
+```go
+ case key.Matches(msg, m.keys.Remote):
+ return m.startRemotePicker()
+```
+
+(Removing `case key.Matches(msg, m.keys.Refresh): return m, m.refreshCmd()` — the only reference to `m.keys.Refresh`. `keys_test.go` and `model_test.go` reference only `refreshCmd`/refresh behavior, not the binding, so they are unaffected.)
+
+Add the view branch in `View()` (in the `switch m.screen`):
+
+```go
+ case screenRemotePicker:
+ if m.remotePicker != nil {
+ content = m.remotePicker.View()
+ }
+```
+
+Add the handlers at the end of `internal/tui/model.go`:
+
+```go
+// startRemotePicker opens the remote-branch picker for the selected item's
+// project and kicks off an async fetch + list.
+func (m Model) startRemotePicker() (tea.Model, tea.Cmd) {
+ sel := m.selectedItem()
+ if sel == nil || sel.Project == "" {
+ return m, nil
+ }
+ m.remotePicker = newRemotePicker(sel.Project, m.cfg, m.tmuxClient)
+ m.screen = screenRemotePicker
+ return m, m.fetchRemoteBranchesCmd(sel.Project)
+}
+
+// fetchRemoteBranchesCmd fetches all remotes (best-effort) and lists the
+// remote-tracking branches for the project.
+func (m Model) fetchRemoteBranchesCmd(project string) tea.Cmd {
+ cfg := m.cfg
+ tmuxClient := m.tmuxClient
+ return func() tea.Msg {
+ wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, &hooks.NoOp{})
+ branches, err := wtSvc.RemoteBranches(project)
+ return remoteBranchesMsg{project: project, branches: branches, err: err}
+ }
+}
+
+// updateRemotePicker handles input while the remote-branch picker is shown.
+func (m Model) updateRemotePicker(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
+ if m.remotePicker.list.FilterState() != list.Filtering {
+ switch keyMsg.String() {
+ case "esc":
+ m.screen = screenList
+ m.remotePicker = nil
+ return m, nil
+ case "enter":
+ if rb, ok := m.remotePicker.selected(); ok {
+ project := m.remotePicker.project
+ m.remotePicker = nil
+ return m.showTrackForm(project, rb)
+ }
+ return m, nil
+ }
+ }
+ }
+ var cmd tea.Cmd
+ m.remotePicker, cmd = m.remotePicker.Update(msg)
+ return m, cmd
+}
+```
+
+`showTrackForm` is added in Task 8. For this task, add a temporary stub at the end of `model.go` so the package compiles and the picker tests pass:
+
+```go
+// showTrackForm opens the create-worktree form pre-filled to track a remote
+// branch. (Filled in by the form-integration task.)
+func (m Model) showTrackForm(project string, rb worktree.RemoteBranch) (tea.Model, tea.Cmd) {
+ m.screen = screenList
+ return m, nil
+}
+```
+
+Ensure `internal/tui/model.go` imports `"charm.land/bubbles/v2/list"` (already imported) and `"github.com/xico42/codeherd/internal/hooks"` (already imported).
+
+- [ ] **Step 8: Run TUI tests to verify they pass**
+
+Run: `go test ./internal/tui/`
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add internal/tui/keys.go internal/tui/keys_test.go internal/tui/model.go internal/tui/remote_picker.go internal/tui/remote_picker_test.go
+git commit -m "feat(tui): rebind r to remote-branch picker with async fetch"
+```
+
+---
+
+## Task 8: TUI form — track a remote branch from the picker
+
+Replaces the Task 7 `showTrackForm` stub with a real handoff into the existing create-worktree form, pre-filled and routed to `NewTracking`.
+
+**Files:**
+- Modify: `internal/tui/form.go`, `internal/tui/model.go`
+- Test: `internal/tui/form_test.go`
+
+**Interfaces:**
+- Consumes: `worktree.Service.NewTracking` (Task 4); `remotePickerModel.selected` (Task 7).
+- Produces: `formContext.tracksRef`/`formContext.branch`; `formModel.tracksRef`; real `Model.showTrackForm`.
+
+- [ ] **Step 1: Write failing tests**
+
+Add to `internal/tui/form_test.go`:
+
+```go
+func TestNewFormModel_trackPrefill(t *testing.T) {
+ cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}}
+ ctx := formContext{project: "myapp", tracksRef: "origin/feat-x", branch: "feat-x"}
+ m := newFormModel(ctx, cfg, nil)
+ if m.tracksRef != "origin/feat-x" {
+ t.Errorf("tracksRef = %q, want origin/feat-x", m.tracksRef)
+ }
+ if m.branch != "feat-x" {
+ t.Errorf("prefilled branch = %q, want feat-x", m.branch)
+ }
+}
+
+func TestShowTrackForm_opensFormScreen(t *testing.T) {
+ cfg := &config.Config{Projects: map[string]config.ProjectConfig{"myapp": {}}}
+ m := Model{cfg: cfg}
+ updated, _ := m.showTrackForm("myapp", worktree.RemoteBranch{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"})
+ mm := updated.(Model)
+ if mm.screen != screenForm {
+ t.Errorf("screen = %d, want screenForm", mm.screen)
+ }
+ if mm.form == nil || mm.form.tracksRef != "origin/feat-x" {
+ t.Errorf("form not set up to track; form=%+v", mm.form)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failures**
+
+Run: `go test ./internal/tui/ -run 'TestNewFormModel_trackPrefill|TestShowTrackForm'`
+Expected: FAIL — `formContext.tracksRef`/`formModel.tracksRef` undefined; stub `showTrackForm` doesn't set the form.
+
+- [ ] **Step 3: Add `tracksRef` to the form and route submission**
+
+In `internal/tui/form.go`, add to `formModel` (after `branch`, ~line 21):
+
+```go
+ tracksRef string
+```
+
+add to `formContext` (lines 34-37):
+
+```go
+type formContext struct {
+ project string
+ baseBranch string
+ tracksRef string
+ branch string // optional pre-fill for the branch input
+}
+```
+
+In `newFormModel`, set the prefill and tracksRef before building the group (after line 46):
+
+```go
+ m.tracksRef = ctx.tracksRef
+ if ctx.branch != "" {
+ m.branch = ctx.branch
+ }
+```
+
+Change the Note so it reflects tracking when active (lines 51-53):
+
+```go
+ huh.NewNote().
+ Title("New Worktree").
+ Description(worktreeFormDescription(ctx)),
+```
+
+and add the helper at the end of `form.go`:
+
+```go
+func worktreeFormDescription(ctx formContext) string {
+ if ctx.tracksRef != "" {
+ return fmt.Sprintf("Project: %s\nTracks: %s", ctx.project, ctx.tracksRef)
+ }
+ return fmt.Sprintf("Project: %s\nBase: %s", ctx.project, ctx.baseBranch)
+}
+```
+
+In `submit()` (lines 117-152), capture `tracksRef` and route to `NewTracking`. Replace the captured-vars block and the service-call block:
+
+```go
+func (f *formModel) submit() tea.Cmd {
+ branch := strings.TrimSpace(f.branch)
+ project := f.project
+ baseBranch := f.baseBranch
+ tracksRef := f.tracksRef
+ attach := f.attach
+ agent := f.agent
+ cfg := f.cfg
+ tmuxClient := f.tmuxClient
+ projCfg := cfg.Projects[project]
+
+ return func() tea.Msg {
+ h := hooks.New(projCfg.Hooks)
+ projSvc := projectpkg.NewService(cfg, projectpkg.NewRealGitRunner(), h)
+ _ = projSvc.Clone(project)
+
+ wtSvc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmuxClient, h)
+ var result worktree.NewResult
+ var err error
+ switch {
+ case tracksRef != "":
+ result, err = wtSvc.NewTracking(project, branch, tracksRef)
+ case baseBranch != "":
+ result, err = wtSvc.NewFrom(project, branch, baseBranch)
+ default:
+ result, err = wtSvc.New(project, branch)
+ }
+ if err != nil {
+ return errMsg{err: err}
+ }
+
+ return worktreeCreatedMsg{
+ project: project,
+ branch: result.Branch,
+ path: result.Path,
+ attach: attach,
+ agent: agent,
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Replace the `showTrackForm` stub in `model.go`**
+
+In `internal/tui/model.go`, replace the temporary `showTrackForm` from Task 7 with:
+
+```go
+// showTrackForm opens the create-worktree form pre-filled to track the selected
+// remote branch, deriving the local branch name from it.
+func (m Model) showTrackForm(project string, rb worktree.RemoteBranch) (tea.Model, tea.Cmd) {
+ ctx := formContext{project: project, tracksRef: rb.Ref, branch: rb.Branch}
+ m.form = newFormModel(ctx, m.cfg, m.tmuxClient)
+ m.screen = screenForm
+ return m, m.form.Init()
+}
+```
+
+- [ ] **Step 5: Run TUI tests to verify they pass**
+
+Run: `go test ./internal/tui/`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/tui/form.go internal/tui/form_test.go internal/tui/model.go
+git commit -m "feat(tui): track remote branch via pre-filled create form"
+```
+
+---
+
+## Task 9: Integration test + full verification
+
+End-to-end test against real git (and the isolated tmux pattern), then the full project gate.
+
+**Files:**
+- Create: `internal/worktree/integration_test.go`
+
+**Interfaces:**
+- Consumes: `worktree.Service.NewTracking`, `RealWorktreeRunner` (Tasks 1-4).
+
+- [ ] **Step 1: Write the integration test**
+
+Create `internal/worktree/integration_test.go`. It builds a real "remote" repo with a branch, clones it into the codeherd layout, then checks the branch out via `NewTracking` and asserts the worktree branch tracks the remote.
+
+```go
+//go:build integration
+
+package worktree
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/xico42/codeherd/internal/config"
+ "github.com/xico42/codeherd/internal/hooks"
+ "github.com/xico42/codeherd/internal/tmux"
+)
+
+func git(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)
+}
+
+func TestNewTracking_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)
+ }
+ git(t, remote, "init", "-b", "main")
+ if err := os.WriteFile(filepath.Join(remote, "README.md"), []byte("hi\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, remote, "add", ".")
+ git(t, remote, "commit", "-m", "init")
+ git(t, remote, "checkout", "-b", "feat-x")
+ if err := os.WriteFile(filepath.Join(remote, "feature.txt"), []byte("x\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, remote, "add", ".")
+ git(t, remote, "commit", "-m", "feature")
+ git(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)
+ }
+ git(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"},
+ },
+ }
+ svc := NewService(cfg, NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{})
+
+ result, err := svc.NewTracking("myapp", "", "feat-x")
+ if err != nil {
+ t.Fatalf("NewTracking: %v", err)
+ }
+ if result.Branch != "feat-x" {
+ t.Errorf("branch = %q, want feat-x", result.Branch)
+ }
+ if _, err := os.Stat(filepath.Join(result.Path, "feature.txt")); err != nil {
+ t.Errorf("expected feature.txt in worktree: %v", err)
+ }
+
+ branch := strings.TrimSpace(git(t, result.Path, "rev-parse", "--abbrev-ref", "HEAD"))
+ if branch != "feat-x" {
+ t.Errorf("worktree branch = %q, want feat-x", branch)
+ }
+ upstream := strings.TrimSpace(git(t, result.Path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"))
+ if upstream != "origin/feat-x" {
+ t.Errorf("upstream = %q, want origin/feat-x", upstream)
+ }
+}
+```
+
+- [ ] **Step 2: Run the integration test**
+
+Run: `go test -tags integration ./internal/worktree/ -run TestNewTracking_endToEnd -v`
+Expected: PASS.
+
+- [ ] **Step 3: Run the full gate**
+
+Run: `make check`
+Expected: coverage ≥ 80%, integration tests pass, lint clean, build succeeds. Fix any coverage shortfall by adding focused unit tests in the affected package, and any lint issues inline.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/worktree/integration_test.go
+git commit -m "test(worktree): end-to-end remote-branch checkout integration"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+
+- "Track remote branch, same name, origin default/omittable" → Task 4 `NewTracking` + `parseRef` (Task 2). ✅
+- "Ref parsed as `[/]`, remote only if configured; slashes preserved" → Task 2 `parseRef` + tests. ✅
+- "`--track`/`--from` mutually exclusive" → Task 5 `MarkFlagsMutuallyExclusive` + test. ✅
+- "CLI `--track`, positional optional + override, composes with attach/agent" → Task 5. ✅
+- "Completion suggests remote branches" → Task 6. ✅
+- "Fresh source on creation: fetch + FF local + base off remote-tracking; FF best-effort/non-fatal; preserve un-pushed commits; local-only refs skip fetch" → Task 1 `FastForward`, Task 3 `freshenStartPoint` + tests. ✅ (Refinement vs spec: FF prefers the *local* branch as the base point so un-pushed commits are never dropped — see Global Constraints; the spec's "base off remote-tracking ref" is used only when no local branch exists.)
+- "WorktreeRunner additions (Fetch/FetchAll/FastForward/Remotes/ListRemoteBranches/AddTracking) + RemoteBranch type" → Task 1. `HasLocalBranch` added beyond the spec to implement the "local branch already exists" error cleanly. ✅
+- "Error handling: fetch failure fatal for track, local-branch-exists error, mutual-exclusion error" → Tasks 4, 5. ✅
+- "TUI `r` picker: blocking-ish async fetch, filterable list, empty message, select → pre-filled form → NewTracking" → Tasks 7, 8. ✅ (`r` is rebound from manual refresh to the remote picker; the manual-refresh binding is removed since the dashboard auto-refreshes every 3s.)
+- "Testing: unit (parse, ordering, derivation, errors, conflict), CLI, TUI, integration; coverage ≥ 80%" → Tasks 1-9. ✅
+- Out-of-scope items (PR numbers, configurable remote, async background refresh) correctly omitted. ✅
+
+**Deviations from spec, intentional and noted in-plan:**
+1. The fast-forward courtesy uses the *local* branch as the base point when it exists (never the remote-tracking ref), to avoid dropping un-pushed local commits — a correctness fix surfaced during planning.
+2. No `NewResult.Notice` for skipped fast-forwards: FF is silent best-effort. The freshness guarantee still holds because either the local branch is fast-forwarded or (when absent) the remote-tracking ref is used.
+3. TUI keybinding is `r`, rebound from the manual dashboard refresh (which is removed — auto-refresh runs every 3s). `refreshCmd` itself is unchanged.
+4. Added `WorktreeRunner.HasLocalBranch` to back the "local branch already exists" error.
+
+**Placeholder scan:** No TBD/TODO/"handle errors appropriately"; every code step shows complete code. ✅
+
+**Type consistency:** `RemoteBranch{Remote,Branch,Ref}`, `NewResult{Path,Branch}`, `NewTracking(project, branch, ref)`, `RemoteBranches(project)`, `ListRemoteBranches(project)`, `freshenStartPoint(cloneDir, src)`, `parseRef(remotes, ref)`, `remotePickerModel.{setBranches,selected}`, `formContext.{tracksRef,branch}` are used identically across tasks. ✅
diff --git a/docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md b/docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md
new file mode 100644
index 0000000..4cbbe79
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-27-worktree-head-divergence-session-correlation.md
@@ -0,0 +1,905 @@
+# Worktree HEAD-Divergence Session Correlation 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:** Keep a codeherd worktree's tmux session visible in the TUI (and `ch list worktree`) when its HEAD diverges from the branch it was created for — a rebase in progress (detached HEAD) or a different branch checked out.
+
+**Architecture:** Correlate worktrees to sessions by a *stable identity branch* instead of the live git branch. For `__worktrees/` directories that identity is the folder base name (which equals the flattened branch); for the main clone dir it is `config.DefaultBranch`. This reproduces the session's frozen `@codeherd_canonical_name` exactly and retroactively. Display the identity branch plus a live-state hint (`detached` / `on `), using a new `@codeherd_branch` tmux option for the exact pretty name with a folder/config fallback.
+
+**Tech Stack:** Go, tmux (via `internal/tmux` typed wrapper), Bubble Tea v2 TUI.
+
+## Global Constraints
+
+- Aggregate test coverage must stay ≥ 80%; `make check` (coverage → integration → lint → build) must pass before completion.
+- Every closed-set string already has a typed Go const; reuse existing `semconv` consts, do not introduce raw string literals for tmux options.
+- TDD: write the failing test first, watch it fail, implement minimally, watch it pass, commit.
+- `FlattenBranch` (slash → dash) is idempotent; rely on that rather than reversing it.
+- Run a single package's tests with `go test ./internal//...`.
+
+---
+
+### Task 1: `semconv` — identity-branch helper and `@codeherd_branch` const
+
+**Files:**
+- Modify: `internal/semconv/semconv.go` (const block at lines 8-27; helpers near `FlattenBranch` at line 59)
+- Test: `internal/semconv/semconv_test.go`
+
+**Interfaces:**
+- Produces: `semconv.TmuxOptionBranch = "@codeherd_branch"` (string const)
+- Produces: `semconv.WorktreeIdentityBranch(path, cloneDir, defaultBranch, liveBranch string) string`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/semconv/semconv_test.go`:
+
+```go
+func TestWorktreeIdentityBranch(t *testing.T) {
+ tests := []struct {
+ name, path, cloneDir, defaultBranch, liveBranch, want string
+ }{
+ {"worktree dir uses folder name", "/p/github.com/u/app__worktrees/feature-x", "/p/github.com/u/app", "main", "", "feature-x"},
+ {"worktree dir ignores live branch", "/p/github.com/u/app__worktrees/feature-x", "/p/github.com/u/app", "main", "other", "feature-x"},
+ {"clone dir uses default branch", "/p/github.com/u/app", "/p/github.com/u/app", "main", "", "main"},
+ {"clone dir falls back to live branch when default unset", "/p/github.com/u/app", "/p/github.com/u/app", "", "develop", "develop"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := semconv.WorktreeIdentityBranch(tc.path, tc.cloneDir, tc.defaultBranch, tc.liveBranch)
+ if got != tc.want {
+ t.Errorf("WorktreeIdentityBranch(%q,%q,%q,%q) = %q, want %q",
+ tc.path, tc.cloneDir, tc.defaultBranch, tc.liveBranch, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestTmuxOptionBranch_constant(t *testing.T) {
+ if semconv.TmuxOptionBranch != "@codeherd_branch" {
+ t.Errorf("TmuxOptionBranch = %q, want @codeherd_branch", semconv.TmuxOptionBranch)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/semconv/...`
+Expected: FAIL — `undefined: semconv.WorktreeIdentityBranch` and `semconv.TmuxOptionBranch`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `internal/semconv/semconv.go`, add the const inside the existing `const (...)` block (after `TmuxOptionProfile`):
+
+```go
+ TmuxOptionProfile = "@codeherd_profile"
+ TmuxOptionBranch = "@codeherd_branch"
+```
+
+Then add the helper just below `FlattenBranch` (after line 61). `path/filepath` is already imported:
+
+```go
+// WorktreeIdentityBranch returns the stable identity branch for a worktree —
+// the branch the worktree was created for, regardless of where HEAD points now.
+// Feeding it into SessionName recovers the session's frozen canonical name.
+//
+// Worktrees under "__worktrees/" are named FlattenBranch(branch), so the
+// directory base name is the (flattened) identity. The main clone dir is named
+// after the repo rather than a branch, so its identity is the configured default
+// branch, falling back to the live branch when no default is configured.
+func WorktreeIdentityBranch(path, cloneDir, defaultBranch, liveBranch string) string {
+ if path == cloneDir {
+ if defaultBranch != "" {
+ return defaultBranch
+ }
+ return liveBranch
+ }
+ return filepath.Base(path)
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/semconv/...`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/semconv/semconv.go internal/semconv/semconv_test.go
+git commit -m "feat(semconv): add WorktreeIdentityBranch helper and @codeherd_branch option"
+```
+
+---
+
+### Task 2: `worktree` — parse detached HEAD and correlate by identity branch
+
+**Files:**
+- Modify: `internal/worktree/worktree.go` (`WorktreeInfo` lines 28-32; `ListEntry` lines 34-40; `parseWorktreePorcelain` lines 223-246; `Service.List` loop lines 585-599)
+- Test: `internal/worktree/worktree_test.go`
+
+**Interfaces:**
+- Consumes: `semconv.WorktreeIdentityBranch` (Task 1)
+- Produces: `WorktreeInfo.Detached bool`, `ListEntry.Detached bool`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `internal/worktree/worktree_test.go`:
+
+```go
+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 TestService_List_cloneDirDetachedUsesDefaultBranch(t *testing.T) {
+ // The clone-dir worktree is detached (e.g. mid-rebase): no live branch.
+ // Correlation must fall back to config DefaultBranch ("main") so the
+ // session is still found and Detached is surfaced.
+ git := &mockGit{
+ listResult: []WorktreeInfo{
+ {Path: "", Branch: "", Detached: true}, // Path filled in below
+ },
+ }
+ svc, tmpDir := makeService(t, git, &mockTmuxRunner{exitCode: 0}) // exit 0 = session exists
+ git.listResult[0].Path = cloneDirPath(tmpDir)
+ if err := os.MkdirAll(cloneDirPath(tmpDir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ entries, err := svc.List("")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(entries))
+ }
+ if entries[0].Session == "" {
+ t.Errorf("expected session populated via DefaultBranch identity, got empty")
+ }
+ if !entries[0].Detached {
+ t.Errorf("expected Detached=true on entry")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/worktree/...`
+Expected: FAIL — `WorktreeInfo` has no field `Detached`; `ListEntry` has no field `Detached`.
+
+- [ ] **Step 3: Implement the struct fields and parsing**
+
+In `internal/worktree/worktree.go`, extend `WorktreeInfo` (lines 28-32):
+
+```go
+// 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)
+}
+```
+
+Extend `ListEntry` (lines 34-40):
+
+```go
+// ListEntry is one row in the worktree list output.
+type ListEntry struct {
+ Project string
+ Branch string
+ Path string
+ Session string // "- (running)" or ""
+ Detached bool // true when the worktree's HEAD is detached
+}
+```
+
+In `parseWorktreePorcelain`, add a case for the `detached` line (inside the `switch` at lines 229-240, alongside the `branch ` case):
+
+```go
+ case strings.HasPrefix(line, "branch "):
+ ref := strings.TrimPrefix(line, "branch ")
+ current.Branch = strings.TrimPrefix(ref, "refs/heads/")
+ case line == "detached":
+ current.Detached = true
+```
+
+- [ ] **Step 4: Implement the `Service.List` identity correlation**
+
+Replace the loop body in `Service.List` (lines 585-599) with:
+
+```go
+ for _, wt := range worktrees {
+ identity := semconv.WorktreeIdentityBranch(wt.Path, cd, p.DefaultBranch, wt.Branch)
+ session := ""
+ if identity != "" {
+ candidate := semconv.SessionName("", name, identity)
+ if running, _ := s.tmux.HasSession(candidate); running {
+ session = candidate + " (running)"
+ }
+ }
+ entries = append(entries, ListEntry{
+ Project: name,
+ Branch: wt.Branch,
+ Path: wt.Path,
+ Session: session,
+ Detached: wt.Detached,
+ })
+ }
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `go test ./internal/worktree/...`
+Expected: PASS (new tests pass; `TestParseWorktreePorcelain`, `TestService_List_withRunningSession`, and the other existing List tests still pass — for `__worktrees/` paths the identity equals the branch, so behavior is unchanged).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/worktree/worktree.go internal/worktree/worktree_test.go
+git commit -m "feat(worktree): track detached HEAD and correlate sessions by identity branch"
+```
+
+---
+
+### Task 3: `tmux` — expose the raw branch from `ListSessions`
+
+**Files:**
+- Modify: `internal/tmux/client.go` (`SessionRecord` lines 9-19; `ListSessions` lines 227-260)
+- Test: `internal/tmux/client_test.go`
+
+**Interfaces:**
+- Produces: `tmux.SessionRecord.Branch string` (populated from `@codeherd_branch`, `""` when the option is unset)
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/tmux/client_test.go`:
+
+```go
+func TestClient_ListSessions_readsBranchOption(t *testing.T) {
+ // 9-field line: branch populated as the final field.
+ line := "$1\tmyapp-feat\tmyapp-feat\tagent\trunning\t\t2026-01-01T00:00:00Z\twork\tfeature/login\n"
+ r := &mockRunner{exitCode: 0, stdout: line}
+ c := tmux.NewClient(r)
+ records, err := c.ListSessions()
+ if err != nil {
+ t.Fatalf("ListSessions() error = %v", err)
+ }
+ if len(records) != 1 {
+ t.Fatalf("len(records) = %d, want 1", len(records))
+ }
+ if records[0].Branch != "feature/login" {
+ t.Errorf("Branch = %q, want feature/login", records[0].Branch)
+ }
+}
+
+func TestClient_ListSessions_missingBranchIsEmpty(t *testing.T) {
+ // Old 8-field line (pre-upgrade session): Branch must default to "".
+ line := "$1\tmyapp-feat\tmyapp-feat\tagent\trunning\t\t2026-01-01T00:00:00Z\twork\n"
+ r := &mockRunner{exitCode: 0, stdout: line}
+ c := tmux.NewClient(r)
+ records, err := c.ListSessions()
+ if err != nil {
+ t.Fatalf("ListSessions() error = %v", err)
+ }
+ if records[0].Branch != "" {
+ t.Errorf("Branch = %q, want empty", records[0].Branch)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/tmux/...`
+Expected: FAIL — `records[0].Branch` undefined (`SessionRecord` has no `Branch` field).
+
+- [ ] **Step 3: Implement the field and parsing**
+
+In `internal/tmux/client.go`, add to `SessionRecord` (after `Profile`, line 18):
+
+```go
+ Profile string // @codeherd_profile, "" when unset
+ Branch string // @codeherd_branch — raw branch the session was created for, "" when unset
+}
+```
+
+In `ListSessions`, append `@codeherd_branch` to the format string (line 228):
+
+```go
+ format := "#{session_id}\t#{session_name}\t#{@codeherd_canonical_name}\t#{@codeherd_session_type}\t#{@codeherd_status}\t#{@codeherd_annotation}\t#{@codeherd_started_at}\t#{@codeherd_profile}\t#{@codeherd_branch}"
+```
+
+Change the split width from 8 to 9 (lines 244-247):
+
+```go
+ fields := strings.SplitN(line, "\t", 9)
+ for len(fields) < 9 {
+ fields = append(fields, "")
+ }
+```
+
+Add `Branch` to the appended record (after `Profile`, line 256):
+
+```go
+ Profile: fields[7],
+ Branch: fields[8],
+ })
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/tmux/...`
+Expected: PASS (new tests pass; existing `TestClient_ListSessions_ok` and the profile tests still pass — their 8-field lines parse with `Branch == ""`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/tmux/client.go internal/tmux/client_test.go
+git commit -m "feat(tmux): surface @codeherd_branch in SessionRecord"
+```
+
+---
+
+### Task 4: `session` — stamp `@codeherd_branch` at session start
+
+**Files:**
+- Modify: `internal/session/session.go` (`SetOption` block lines 144-150)
+- Test: `internal/session/session_test.go` (`TestStart_OK` at lines 96-125)
+
+**Interfaces:**
+- Consumes: `semconv.TmuxOptionBranch` (Task 1)
+- Produces: a `set-option @codeherd_branch ` tmux call during `Start`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/session/session_test.go`:
+
+```go
+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", ,