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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

Expand Down Expand Up @@ -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
Expand All @@ -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.<name>]` 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
Expand Down
58 changes: 35 additions & 23 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -78,37 +88,38 @@ 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
}

// 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
Expand All @@ -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
}
Expand Down
31 changes: 17 additions & 14 deletions cmd/completion_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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{})
Expand All @@ -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 })
Expand All @@ -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 })

Expand Down
68 changes: 30 additions & 38 deletions cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
60 changes: 60 additions & 0 deletions cmd/errors_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 4 additions & 8 deletions cmd/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
Loading
Loading