diff --git a/README.md b/README.md index 57abfc6..d801667 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,53 @@ the launch context: `{{.WorkDir}}` (where you launched from), `{{.SessionTitle}} as `HERDR_PLUS_*` environment variables. If a command doesn't reference `{{.Value}}`, the value is appended as a final shell-quoted argument. +## Worktree auto-layout + +herdr-plus can lay a project-style tab layout into a git **worktree** the moment +herdr creates it. When you run `herdr worktree create` (or open one), herdr makes a +fresh workspace for the worktree and fires a `worktree.created` event; herdr-plus +catches it, finds a layout matching the worktree's repo, and opens that layout's +tabs and panes in the new workspace — every command running — with no keypress. +This is the plugin system's `[[events]]` hook (declared in +[`herdr-plugin.toml`](herdr-plugin.toml)) put to work. + +Layouts live in `~/.config/herdr-plus/worktrees/`, one TOML file per layout (the +file name doesn't matter). A layout is a `repo` matcher plus the same `[[tabs]]` +format projects use: + +```toml +repo = "options-cafe" # matches the worktree's repo name (case-insensitive) + +[[tabs]] +name = "claude" +command = "claude --dangerously-skip-permissions --chrome" + +[[tabs]] +name = "lazygit" +command = "lazygit" + +[[tabs]] +name = "terminal" # no command — just an empty shell +``` + +- **`repo`** (required) matches the new worktree's repository name — the repo's + basename, e.g. `options-cafe` — case-insensitively. +- **`branch`** (optional) narrows a layout to worktrees created on exactly that + branch. When more than one layout matches, a branch-specific one wins over a + repo-only one. +- **`[[tabs]]`** is identical to a project's tabs, including multi-pane + `[[tabs.panes]]` splits (see [Split panes within a tab](#split-panes-within-a-tab)). + +### Turning a layout on and off + +The switch is simply **whether the file exists**. A layout in `worktrees/` is on; +to turn one off, delete the file (or move it out of the directory). With no files +in `worktrees/` at all, the feature is inert — every worktree fires the event, and +herdr-plus does nothing when nothing matches. + +The handler's output shows up in `herdr plugin log list --plugin +cloudmanic.herdr-plus`, so you can confirm whether a layout fired. + ## Binding a key Binding keys to the actions is an optional, one-time edit to **your** herdr diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 19901c0..8169ea9 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -71,3 +71,13 @@ command = ["./bin/herdr-plus", "quick-actions-ui"] id = "ping" title = "Herdr Plus: ping" command = ["./bin/herdr-plus", "ping"] + +# worktree.created — fired by herdr every time it creates a git worktree. The +# handler looks for a matching, enabled layout in ~/.config/herdr-plus/worktrees/ +# and lays its tabs/panes into the workspace herdr just made. It is a quiet no-op +# when no layout matches (or the matching one has `enabled = false`), so it is +# safe to subscribe unconditionally. herdr runs this for you — never invoke it by +# hand. Output is captured in the plugin log. +[[events]] +on = "worktree.created" +command = ["./bin/herdr-plus", "on-worktree-created"] diff --git a/main.go b/main.go index 8e20de4..36c95b7 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,8 @@ import ( // pane it opens (the `picker` / `quick-actions-picker` entrypoints), so end // users never run them directly. // - "ping" is a smoke test that proves the plugin loop end to end. +// - "on-worktree-created" is herdr's worktree.created event handler — herdr runs +// it (via the [[events]] entry in herdr-plugin.toml), not the user. // // The bare binary has no launcher of its own, so it just prints usage. func main() { @@ -43,6 +45,9 @@ func main() { case "ping": runPing() return + case "on-worktree-created": + runOnWorktreeCreated(os.Args[2:]) + return case "version", "--version", "-v", "-V": fmt.Println("herdr-plus", version.Version) return diff --git a/project.go b/project.go index 6a4e31f..b551a42 100644 --- a/project.go +++ b/project.go @@ -178,15 +178,24 @@ func (p Project) validate() error { if len(p.Tabs) == 0 { return fmt.Errorf("project %q (%s): needs at least one [[tabs]] entry", p.Name, p.source) } - for i, t := range p.Tabs { + return validateTabs(p.Name, p.source, p.Tabs) +} + +// validateTabs checks the per-tab rules shared by projects and worktree layouts: +// every tab needs a name; a tab uses either a single command or [[tabs.panes]], +// never both; a tab holds at most maxPanesPerTab panes; and every non-root pane's +// split is "down" or "right". label and source identify the owning config in +// error messages — a project's name or a layout's repo, and the file it came from. +func validateTabs(label, source string, tabs []ProjectTab) error { + for i, t := range tabs { if strings.TrimSpace(t.Name) == "" { - return fmt.Errorf("project %q (%s): tab %d is missing a name", p.Name, p.source, i+1) + return fmt.Errorf("%q (%s): tab %d is missing a name", label, source, i+1) } if len(t.Panes) > 0 && strings.TrimSpace(t.Command) != "" { - return fmt.Errorf("project %q (%s): tab %q sets both command and [[tabs.panes]]; use one or the other", p.Name, p.source, t.Name) + return fmt.Errorf("%q (%s): tab %q sets both command and [[tabs.panes]]; use one or the other", label, source, t.Name) } if len(t.Panes) > maxPanesPerTab { - return fmt.Errorf("project %q (%s): tab %q has %d panes; at most %d are allowed", p.Name, p.source, t.Name, len(t.Panes), maxPanesPerTab) + return fmt.Errorf("%q (%s): tab %q has %d panes; at most %d are allowed", label, source, t.Name, len(t.Panes), maxPanesPerTab) } for j, pane := range t.Panes { if j == 0 { @@ -196,7 +205,7 @@ func (p Project) validate() error { case "", SplitDown, SplitRight: // ok — an empty split defaults to "down" default: - return fmt.Errorf("project %q (%s): tab %q pane %d has split %q; must be %q or %q", p.Name, p.source, t.Name, j+1, pane.Split, SplitDown, SplitRight) + return fmt.Errorf("%q (%s): tab %q pane %d has split %q; must be %q or %q", label, source, t.Name, j+1, pane.Split, SplitDown, SplitRight) } } } diff --git a/worktree.go b/worktree.go new file mode 100644 index 0000000..2013b0d --- /dev/null +++ b/worktree.go @@ -0,0 +1,261 @@ +// +// Date: 2026-06-16 +// Author: Spicer Matthews (spicer@cloudmanic.com) +// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. +// + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// WorktreeLayout is a declarative tab layout applied automatically when a herdr +// git worktree is created. Where a Project is opened on demand and creates its +// own workspace, a worktree layout reacts to herdr's `worktree.created` event: +// herdr has already made the worktree's workspace, so the layout just fills it +// with tabs. It reuses the same ProjectTab model, so worktree layouts get the +// full single-command / split-pane vocabulary that projects have. +// +// Layouts live in ~/.config/herdr-plus/worktrees/, one TOML file per layout (the +// file name does not matter). With no files there, the feature is simply inert. +type WorktreeLayout struct { + // Repo selects which worktrees this layout applies to, matched + // case-insensitively against the new worktree's repo name (the repository's + // basename, e.g. "options-cafe"). Required. + Repo string `toml:"repo"` + + // Branch, when set, narrows the layout to worktrees created on exactly this + // branch (case-insensitive). Empty applies to every branch of the repo, and a + // branch-specific layout is preferred over a repo-only one when both match. + Branch string `toml:"branch"` + + // Tabs is the ordered list of tabs to open in the worktree's workspace, + // identical in shape to a Project's tabs (a single `command`, or multiple + // [[tabs.panes]] splits). + Tabs []ProjectTab `toml:"tabs"` + + // source is the file the layout was loaded from, used only for error and log + // messages. It is not part of the on-disk format. + source string +} + +// validate checks that a layout is internally consistent before we ever act on a +// worktree event, turning config mistakes into clear errors at load time. +func (l WorktreeLayout) validate() error { + if strings.TrimSpace(l.Repo) == "" { + return fmt.Errorf("worktree layout %s: repo is required", l.source) + } + if len(l.Tabs) == 0 { + return fmt.Errorf("worktree layout %q (%s): needs at least one [[tabs]] entry", l.Repo, l.source) + } + return validateTabs(l.Repo, l.source, l.Tabs) +} + +// matches reports whether this layout applies to the given worktree event. The +// repo must match (against either the repo name or the basename of the repo +// root, case-insensitively); a layout with a Branch additionally requires the +// worktree's branch to match. +func (l WorktreeLayout) matches(ev worktreeEvent) bool { + repo := strings.TrimSpace(l.Repo) + if !strings.EqualFold(repo, ev.RepoName) && !strings.EqualFold(repo, filepath.Base(ev.RepoRoot)) { + return false + } + if l.Branch != "" && !strings.EqualFold(strings.TrimSpace(l.Branch), ev.Branch) { + return false + } + return true +} + +// worktreesConfigDir returns the directory that holds worktree layout files, +// ~/.config/herdr-plus/worktrees. Like projects, it hangs directly off the +// herdr-plus config root rather than under a mode slug. +func worktreesConfigDir() (string, error) { + base, err := configBaseDir() + if err != nil { + return "", err + } + return filepath.Join(base, "worktrees"), nil +} + +// loadWorktreeLayouts reads, parses, and validates every *.toml layout in the +// worktrees directory, returning them sorted by file name. A missing directory +// yields no layouts and no error, so the feature is opt-in: with nothing there, +// the worktree handler is a no-op. A malformed or invalid file fails the whole +// load with a message naming the offending files, so config mistakes surface in +// the plugin log instead of a layout silently going missing. +func loadWorktreeLayouts() ([]WorktreeLayout, error) { + dir, err := worktreesConfigDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var layouts []WorktreeLayout + var problems []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { + continue + } + path := filepath.Join(dir, e.Name()) + + var l WorktreeLayout + if _, err := toml.DecodeFile(path, &l); err != nil { + problems = append(problems, fmt.Sprintf(" %s: %v", e.Name(), err)) + continue + } + l.source = e.Name() + if err := l.validate(); err != nil { + problems = append(problems, " "+err.Error()) + continue + } + layouts = append(layouts, l) + } + + if len(problems) > 0 { + return nil, fmt.Errorf("invalid worktree layout files in %s:\n%s", dir, strings.Join(problems, "\n")) + } + + sort.Slice(layouts, func(i, j int) bool { return layouts[i].source < layouts[j].source }) + return layouts, nil +} + +// matchWorktreeLayout returns the best matching layout for an event, if any. +// Among all matching layouts a branch-specific one wins over a repo-only one (it +// is more specific); ties break by file name, which loadWorktreeLayouts already +// sorts by. +func matchWorktreeLayout(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLayout, bool) { + var best WorktreeLayout + found := false + for _, l := range layouts { + if !l.matches(ev) { + continue + } + if !found { + best, found = l, true + continue + } + // A branch-specific match beats the repo-only match we already have. + if best.Branch == "" && l.Branch != "" { + best = l + } + } + return best, found +} + +// worktreeEvent is the subset of the `worktree.created` payload herdr-plus acts +// on: the repo and branch (for matching a layout) plus the ids of the workspace, +// root tab, and root pane herdr already created for the worktree (to lay the +// layout into). +type worktreeEvent struct { + WorkspaceID string + RootTabID string + RootPaneID string + RepoName string + RepoRoot string + Branch string + CheckoutPath string +} + +// worktreeCreatedPayload mirrors the JSON herdr puts in HERDR_PLUGIN_EVENT_JSON +// for a `worktree.created` event. Only the fields we use are declared. +type worktreeCreatedPayload struct { + Data struct { + Workspace struct { + WorkspaceID string `json:"workspace_id"` + ActiveTabID string `json:"active_tab_id"` + Worktree struct { + RepoName string `json:"repo_name"` + RepoRoot string `json:"repo_root"` + CheckoutPath string `json:"checkout_path"` + } `json:"worktree"` + } `json:"workspace"` + Worktree struct { + Path string `json:"path"` + Branch string `json:"branch"` + } `json:"worktree"` + } `json:"data"` +} + +// parseWorktreeEvent builds a worktreeEvent from the event JSON and the plugin +// environment. herdr provides the workspace/tab/pane ids both as HERDR_* env vars +// and (for workspace and tab) inside the payload; we prefer the env vars and fall +// back to the payload. The root pane id comes only from HERDR_PANE_ID. getenv is +// injected so the parsing is unit-testable without touching the real environment. +func parseWorktreeEvent(eventJSON string, getenv func(string) string) (worktreeEvent, error) { + var p worktreeCreatedPayload + if strings.TrimSpace(eventJSON) != "" { + if err := json.Unmarshal([]byte(eventJSON), &p); err != nil { + return worktreeEvent{}, fmt.Errorf("parse HERDR_PLUGIN_EVENT_JSON: %w", err) + } + } + return worktreeEvent{ + WorkspaceID: firstNonEmpty(getenv("HERDR_WORKSPACE_ID"), p.Data.Workspace.WorkspaceID), + RootTabID: firstNonEmpty(getenv("HERDR_TAB_ID"), p.Data.Workspace.ActiveTabID), + RootPaneID: getenv("HERDR_PANE_ID"), + RepoName: p.Data.Workspace.Worktree.RepoName, + RepoRoot: p.Data.Workspace.Worktree.RepoRoot, + Branch: p.Data.Worktree.Branch, + CheckoutPath: firstNonEmpty(p.Data.Worktree.Path, p.Data.Workspace.Worktree.CheckoutPath), + }, nil +} + +// runOnWorktreeCreated is the `worktree.created` event handler herdr invokes (via +// the [[events]] entry in herdr-plugin.toml). It finds the enabled layout that +// matches the new worktree and lays its tabs into the workspace herdr already +// created. With no matching layout it does nothing — every worktree fires this, so +// a quiet no-op is the common, correct case. Output goes to stdout/stderr, which +// herdr captures in the plugin log (`herdr plugin log list --plugin +// cloudmanic.herdr-plus`). +func runOnWorktreeCreated(_ []string) { + ev, err := parseWorktreeEvent(os.Getenv("HERDR_PLUGIN_EVENT_JSON"), os.Getenv) + if err != nil { + errExit("worktree event:", err) + } + + layouts, err := loadWorktreeLayouts() + if err != nil { + errExit(err) + } + + layout, ok := matchWorktreeLayout(layouts, ev) + if !ok { + // No layout for this repo/branch — the expected case for most worktrees. + // The feature is opt-in: a layout exists only if you put a file in + // worktrees/, so a quiet no-op here is the common, correct path. + fmt.Printf("herdr-plus: no worktree layout matches repo %q (branch %q); nothing to do.\n", ev.RepoName, ev.Branch) + return + } + + // We need the workspace herdr made for the worktree and its root tab/pane to + // build on. They should always be present for a worktree.created event; bail + // loudly if not so the failure is visible in the plugin log. + if ev.WorkspaceID == "" || ev.RootTabID == "" || ev.RootPaneID == "" { + errExit(fmt.Sprintf("worktree event missing ids (workspace=%q tab=%q pane=%q)", ev.WorkspaceID, ev.RootTabID, ev.RootPaneID)) + } + + client, err := newHerdrClient() + if err != nil { + errExit(err) + } + + if err := layoutTabs(client, ev.WorkspaceID, ev.RootTabID, ev.RootPaneID, layout.Tabs); err != nil { + errExit("apply worktree layout:", err) + } + + fmt.Printf("herdr-plus: applied worktree layout %q to repo %q (branch %q): %d tab(s).\n", layout.source, ev.RepoName, ev.Branch, len(layout.Tabs)) +} diff --git a/worktree_test.go b/worktree_test.go new file mode 100644 index 0000000..a2dfe4a --- /dev/null +++ b/worktree_test.go @@ -0,0 +1,213 @@ +// +// Date: 2026-06-16 +// Author: Spicer Matthews (spicer@cloudmanic.com) +// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. +// + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// realEventJSON is a verbatim HERDR_PLUGIN_EVENT_JSON payload captured from herdr +// 0.7.0 for a worktree.created event. Parsing the real thing (not a hand-made +// approximation) is what keeps parseWorktreeEvent honest against herdr's wire +// format. +const realEventJSON = `{"event":"worktree_created","data":{"type":"worktree_created","workspace":{"workspace_id":"w5","number":11,"label":"WT Probe","focused":false,"pane_count":1,"tab_count":1,"active_tab_id":"w5:t1","agent_status":"unknown","worktree":{"repo_key":"/private/tmp/wt-probe-repo/.git","repo_name":"wt-probe-repo","repo_root":"/tmp/wt-probe-repo","checkout_path":"/tmp/wt-probe-repo-wt","is_linked_worktree":true}},"worktree":{"path":"/tmp/wt-probe-repo-wt","branch":"probe-wt","is_bare":false,"is_detached":false,"is_prunable":false,"is_linked_worktree":true,"open_workspace_id":"w5","label":"wt-probe-repo"}}}` + +// mapEnv turns a map into a getenv-style lookup for injecting a fake environment. +func mapEnv(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +// TestParseWorktreeEventReal parses the real payload plus the HERDR_* env vars +// herdr sets, and confirms every field herdr-plus relies on is extracted. +func TestParseWorktreeEventReal(t *testing.T) { + env := mapEnv(map[string]string{ + "HERDR_WORKSPACE_ID": "w5", + "HERDR_TAB_ID": "w5:t1", + "HERDR_PANE_ID": "w5:p1", + }) + + ev, err := parseWorktreeEvent(realEventJSON, env) + if err != nil { + t.Fatalf("parseWorktreeEvent: %v", err) + } + + for _, c := range []struct{ name, got, want string }{ + {"WorkspaceID", ev.WorkspaceID, "w5"}, + {"RootTabID", ev.RootTabID, "w5:t1"}, + {"RootPaneID", ev.RootPaneID, "w5:p1"}, + {"RepoName", ev.RepoName, "wt-probe-repo"}, + {"RepoRoot", ev.RepoRoot, "/tmp/wt-probe-repo"}, + {"Branch", ev.Branch, "probe-wt"}, + {"CheckoutPath", ev.CheckoutPath, "/tmp/wt-probe-repo-wt"}, + } { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } +} + +// TestParseWorktreeEventEnvFallback confirms the workspace and tab ids fall back +// to the payload when the env vars are absent (the root pane id has no payload +// fallback, so it stays empty). +func TestParseWorktreeEventEnvFallback(t *testing.T) { + ev, err := parseWorktreeEvent(realEventJSON, mapEnv(nil)) + if err != nil { + t.Fatalf("parseWorktreeEvent: %v", err) + } + if ev.WorkspaceID != "w5" || ev.RootTabID != "w5:t1" { + t.Fatalf("fallback ids = %q/%q, want w5/w5:t1", ev.WorkspaceID, ev.RootTabID) + } + if ev.RootPaneID != "" { + t.Fatalf("RootPaneID = %q, want empty (no payload fallback)", ev.RootPaneID) + } +} + +// TestParseWorktreeEventBadJSON confirms malformed event JSON is a clear error, +// while an empty payload parses to a zero event (so the handler degrades to a +// no-op rather than crashing). +func TestParseWorktreeEventBadJSON(t *testing.T) { + if _, err := parseWorktreeEvent("{not json", mapEnv(nil)); err == nil { + t.Fatal("expected an error for malformed event JSON") + } + if ev, err := parseWorktreeEvent("", mapEnv(nil)); err != nil || ev.RepoName != "" { + t.Fatalf("empty payload = %+v, %v; want zero event, no error", ev, err) + } +} + +// TestMatchWorktreeLayout covers the matching rules: repo match wins, a +// branch-specific layout beats a repo-only one, case is ignored, the repo can be +// matched against the repo-root basename, and a non-match yields nothing. +func TestMatchWorktreeLayout(t *testing.T) { + repoOnly := WorktreeLayout{Repo: "wt-probe-repo", Tabs: []ProjectTab{{Name: "x"}}, source: "a.toml"} + branchy := WorktreeLayout{Repo: "WT-Probe-Repo", Branch: "probe-wt", Tabs: []ProjectTab{{Name: "y"}}, source: "b.toml"} + other := WorktreeLayout{Repo: "something-else", Tabs: []ProjectTab{{Name: "z"}}, source: "c.toml"} + + ev, err := parseWorktreeEvent(realEventJSON, mapEnv(nil)) + if err != nil { + t.Fatalf("parseWorktreeEvent: %v", err) + } + + // Branch-specific layout is preferred over the repo-only one (order-independent). + if got, ok := matchWorktreeLayout([]WorktreeLayout{repoOnly, branchy, other}, ev); !ok || got.source != "b.toml" { + t.Fatalf("match = %q, %v; want b.toml (branch-specific wins)", got.source, ok) + } + if got, ok := matchWorktreeLayout([]WorktreeLayout{branchy, repoOnly}, ev); !ok || got.source != "b.toml" { + t.Fatalf("match (reordered) = %q, %v; want b.toml", got.source, ok) + } + + // Repo-only match when no branch-specific layout is present. + if got, ok := matchWorktreeLayout([]WorktreeLayout{repoOnly, other}, ev); !ok || got.source != "a.toml" { + t.Fatalf("match = %q, %v; want a.toml", got.source, ok) + } + + // No match for an unrelated repo. + if _, ok := matchWorktreeLayout([]WorktreeLayout{other}, ev); ok { + t.Fatal("expected no match for an unrelated repo") + } + + // Branch-specific layout does NOT match a different branch. + otherBranch := WorktreeLayout{Repo: "wt-probe-repo", Branch: "main", Tabs: []ProjectTab{{Name: "y"}}, source: "d.toml"} + if _, ok := matchWorktreeLayout([]WorktreeLayout{otherBranch}, ev); ok { + t.Fatal("branch-specific layout should not match a different branch") + } + + // Repo matched via the basename of repo_root when repo_name is empty. + byRoot := worktreeEvent{RepoRoot: "/Users/me/Development/options-cafe", Branch: "main"} + if _, ok := matchWorktreeLayout([]WorktreeLayout{{Repo: "options-cafe", Tabs: []ProjectTab{{Name: "x"}}}}, byRoot); !ok { + t.Fatal("expected a match against the repo-root basename") + } +} + +// TestWorktreeLayoutValidate confirms a layout needs a repo and at least one tab, +// and that it inherits the shared per-tab validation (a bad split is rejected). +func TestWorktreeLayoutValidate(t *testing.T) { + cases := []struct { + name string + layout WorktreeLayout + wantErr bool + }{ + {"valid", WorktreeLayout{Repo: "r", Tabs: []ProjectTab{{Name: "t", Command: "ls"}}}, false}, + {"no repo", WorktreeLayout{Tabs: []ProjectTab{{Name: "t"}}}, true}, + {"no tabs", WorktreeLayout{Repo: "r"}, true}, + {"bad split", WorktreeLayout{Repo: "r", Tabs: []ProjectTab{{Name: "t", Panes: []ProjectPane{{}, {Split: "sideways"}}}}}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if err := c.layout.validate(); (err != nil) != c.wantErr { + t.Fatalf("validate() err = %v, wantErr = %v", err, c.wantErr) + } + }) + } +} + +// TestLoadWorktreeLayouts round-trips real files through the loader: a missing +// directory yields nothing (the feature is opt-in), and valid layouts load with +// their tabs intact, sorted by file name. +func TestLoadWorktreeLayouts(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + // No worktrees directory yet → no layouts, no error. + got, err := loadWorktreeLayouts() + if err != nil || got != nil { + t.Fatalf("loadWorktreeLayouts (no dir) = %v, %v; want nil, nil", got, err) + } + + dir := filepath.Join(tmp, "herdr-plus", "worktrees") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + layout := `repo = "options-cafe" +branch = "main" + +[[tabs]] +name = "claude" +command = "claude" + +[[tabs]] +name = "lazygit" +command = "lazygit" +` + if err := os.WriteFile(filepath.Join(dir, "options-cafe.toml"), []byte(layout), 0o644); err != nil { + t.Fatal(err) + } + + // A second, repo-only layout to confirm multiple files load and sort by name. + second := `repo = "bevio" + +[[tabs]] +name = "terminal" +` + if err := os.WriteFile(filepath.Join(dir, "bevio.toml"), []byte(second), 0o644); err != nil { + t.Fatal(err) + } + + got, err = loadWorktreeLayouts() + if err != nil { + t.Fatalf("loadWorktreeLayouts: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d layouts, want 2", len(got)) + } + // Sorted by file name: bevio.toml then options-cafe.toml. + if got[0].Repo != "bevio" || len(got[0].Tabs) != 1 { + t.Fatalf("loaded layout[0] = %+v, want bevio with 1 tab", got[0]) + } + if got[1].Repo != "options-cafe" || got[1].Branch != "main" || len(got[1].Tabs) != 2 { + t.Fatalf("loaded layout[1] = %+v, want options-cafe/main with 2 tabs", got[1]) + } + + // A malformed file fails the whole load with a naming error. + if err := os.WriteFile(filepath.Join(dir, "bad.toml"), []byte("repo = "), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadWorktreeLayouts(); err == nil { + t.Fatal("expected an error from a malformed layout file") + } +} diff --git a/www/content/docs/_index.md b/www/content/docs/_index.md index 1e1e136..b56475e 100644 --- a/www/content/docs/_index.md +++ b/www/content/docs/_index.md @@ -33,6 +33,8 @@ managed plugin config directory. We expect the list of features to grow. - **[Projects](projects/)** — declarative workspace templates that spin up a whole herdr workspace of tabs and panes. - **[Quick Actions](quick-actions/)** — the fuzzy launcher and per-project actions. +- **[Worktree Auto-Layout](worktrees/)** — auto-open a tab layout whenever herdr + creates a matching git worktree, with a per-layout on/off switch. If you just want the reference, jump to [Keybindings](keybindings/), the diff --git a/www/content/docs/configuration.md b/www/content/docs/configuration.md index f7dd66d..fc008b7 100644 --- a/www/content/docs/configuration.md +++ b/www/content/docs/configuration.md @@ -1,6 +1,6 @@ --- title: "Configuration" -description: "The herdr-plus config directory: herdr's managed plugin dir, the projects/ and quick-actions/ subdirs, the file-per-entry model, and per-repo overrides." +description: "The herdr-plus config directory: herdr's managed plugin dir, the projects/, quick-actions/, and worktrees/ subdirs, the file-per-entry model, and per-repo overrides." weight: 90 --- @@ -36,6 +36,9 @@ upgrade**, so your projects and quick actions survive a reinstall. github.toml google.toml ... + worktrees/ # one *.toml per worktree auto-layout + options-cafe.toml + ... ``` - **`projects/`** holds your [project templates](../projects/). Each `*.toml` @@ -44,6 +47,10 @@ upgrade**, so your projects and quick actions survive a reinstall. - **`quick-actions/`** holds your [quick actions](../quick-actions/). Each `*.toml` defines one action. This directory is **seeded with editable examples** the first time you open the launcher. +- **`worktrees/`** holds your [worktree auto-layouts](../worktrees/). Each `*.toml` + defines one layout that fires when herdr creates a matching git worktree. This + directory is **never created or seeded** — add it yourself to opt in. A layout + is on simply by existing; delete the file to turn it off. ## The file-per-entry model @@ -62,6 +69,8 @@ sorted by their `name` in the UI. example won't make it reappear. - `projects/` is never seeded. An empty directory is meaningful: it triggers the Projects onboarding empty-state. +- `worktrees/` is never created or seeded. It exists only if you add it, which is + how the [worktree auto-layout](../worktrees/) feature stays opt-in. ## Per-project (per-repo) overrides diff --git a/www/content/docs/examples.md b/www/content/docs/examples.md index f7f2415..45abf31 100644 --- a/www/content/docs/examples.md +++ b/www/content/docs/examples.md @@ -254,8 +254,47 @@ name = "edit" command = "nvim ." ``` +## Worktree auto-layouts + +These live in `worktrees/` and fire automatically when herdr creates a git +worktree of a matching repo. See [Worktree Auto-Layout](../worktrees/) for the +full behavior. + +### Auto-open tabs for a repo's worktrees + +```toml +# options-cafe.toml +repo = "options-cafe" # matches the worktree's repo, case-insensitive + +[[tabs]] +name = "claude" +command = "claude --dangerously-skip-permissions --chrome" + +[[tabs]] +name = "lazygit" +command = "lazygit" + +[[tabs]] +name = "terminal" # no command — just an empty shell +``` + +### A branch-specific layout + +When more than one layout matches, a branch-specific one wins over a repo-only one. + +```toml +# options-cafe-release.toml +repo = "options-cafe" +branch = "release" + +[[tabs]] +name = "deploy" +command = "./scripts/release.sh" +``` + ## See also - [Actions Reference](../actions/) — the full action format. - [Projects](../projects/) — the full project schema. +- [Worktree Auto-Layout](../worktrees/) — auto-deploy a layout on worktree creation. - [Template Variables](../variables/) — context you can use in commands. diff --git a/www/content/docs/troubleshooting.md b/www/content/docs/troubleshooting.md index bbd98ed..7a04486 100644 --- a/www/content/docs/troubleshooting.md +++ b/www/content/docs/troubleshooting.md @@ -74,6 +74,29 @@ Opening a project fails with "working directory does not exist" when its checked at open time, not load time, so the same file can be valid elsewhere. Fix the `working_dir` (remember `~` and `$VARS` expand). See [Projects](../projects/). +## My worktree didn't get its layout + +Creating a git worktree opened a plain workspace instead of your tabs. The +[worktree auto-layout](../worktrees/) handler runs on herdr's `worktree.created` +event and logs why it did or didn't act — check the plugin log first: + +```bash +herdr plugin log list --plugin cloudmanic.herdr-plus +``` + +Common causes: + +- **No file for that repo.** A layout is on only if a file for it exists in + `worktrees/`. Confirm the file is there (not in `projects/`) and hasn't been + deleted or moved. +- **No matching layout.** The log says `no worktree layout matches repo …`. The + `repo` in your layout must match the worktree's repo name (its basename), + case-insensitively. +- **A branch mismatch.** A layout with a `branch` only fires for worktrees created + on exactly that branch. Drop the `branch` line to apply to every branch. +- **A config typo.** An invalid file fails the whole load with an error naming the + file — fix it and create the worktree again. + ## Template errors in a command The `command` is a Go `text/template`. A bad field name or malformed `{{...}}` diff --git a/www/content/docs/worktrees.md b/www/content/docs/worktrees.md new file mode 100644 index 0000000..9474aab --- /dev/null +++ b/www/content/docs/worktrees.md @@ -0,0 +1,120 @@ +--- +title: "Worktree Auto-Layout" +description: "Automatically lay a project-style tab layout into a git worktree the moment herdr creates it, driven by the plugin's worktree.created event — with a per-layout on/off switch." +weight: 65 +--- + +herdr-plus can open a project-style tab layout **automatically** when herdr +creates a git worktree — no keypress, no picker. It's the plugin system's +[`[[events]]`](https://herdr.dev/docs/plugins/) hook put to work: herdr-plus +subscribes to the `worktree.created` event and fills the new worktree's workspace +for you. + +## How it works + +When you run `herdr worktree create` (or `herdr worktree open`), herdr: + +1. Creates the git worktree. +2. Makes a fresh herdr **workspace** for it, rooted at the worktree's directory. +3. Fires a `worktree.created` event. + +herdr-plus catches that event, looks at the worktree's **repo** (and branch), +finds a matching layout, and opens that layout's tabs and panes in the workspace +herdr just made — running every startup command. Because herdr has already +created the workspace and its first tab, herdr-plus only has to fill it in. + +This reuses the exact same tab/pane model as [Projects](../projects/), so a +worktree layout can do everything a project tab can, including multi-pane splits. + +## Configuring a layout + +Layouts live in `~/.config/herdr-plus/worktrees/` (honoring `$XDG_CONFIG_HOME`), +one TOML file per layout — the file name doesn't matter. A layout is a `repo` +matcher plus an ordered list of `[[tabs]]`: + +```toml +repo = "options-cafe" # matches the worktree's repo name (case-insensitive) + +[[tabs]] +name = "claude" +command = "claude --dangerously-skip-permissions --chrome" + +[[tabs]] +name = "lazygit" +command = "lazygit" + +[[tabs]] +name = "terminal" # no command — just an empty shell +``` + +Create a worktree of `options-cafe` and you land in a workspace with three tabs — +`claude` running, `lazygit` running, and an empty `terminal` — every time. + +## Turning a layout on and off + +The switch is simply **whether the file exists**: + +- **On:** a `*.toml` file in `worktrees/` for the repo. Create a worktree of that + repo and the layout opens. +- **Off:** delete the file (or move it out of `worktrees/`). Worktrees of that repo + then open as a plain workspace, unchanged. + +With no `worktrees/` directory — or an empty one — herdr-plus does nothing at all. +The event still fires for every worktree; herdr-plus just has no layout to apply. +There's no separate enable/disable flag to keep in sync: the file's presence *is* +the switch. + +## Matching rules + +- **`repo`** (required) is matched case-insensitively against the new worktree's + repository name (the repo's basename, e.g. `options-cafe`). It also matches the + basename of the repo's root path, so it works regardless of how the worktree was + created. +- **`branch`** (optional) narrows a layout to worktrees created on exactly that + branch (case-insensitive). Leave it off to apply to every branch of the repo. +- When **more than one layout matches** the same worktree, a branch-specific + layout wins over a repo-only one; otherwise the first by file name is used. + +```toml +# A branch-specific layout: only worktrees on the "release" branch get this one. +repo = "options-cafe" +branch = "release" + +[[tabs]] +name = "deploy" +command = "./scripts/release.sh" +``` + +## Tabs and split panes + +The `[[tabs]]` format is identical to a project's. A tab can run a single +`command`, or hold up to four panes via `[[tabs.panes]]` with `split = "down"` or +`"right"`. See [Split panes within a tab](../projects/#split-panes-within-a-tab) +for the full vocabulary. + +## When nothing matches + +Every worktree creation fires the event, but if no layout matches the repo, +herdr-plus does nothing — the feature is opt-in and silent. With no `worktrees/` +directory at all, it's simply inert. + +## Where it runs + +The handler is the plugin's `worktree.created` event, declared in +`herdr-plugin.toml`. herdr runs it for you (you never invoke it by hand), and its +output is captured in the plugin log: + +```bash +herdr plugin log list --plugin cloudmanic.herdr-plus +``` + +A line like `applied worktree layout "options-cafe.toml" to repo "options-cafe"` +confirms a layout fired. `no worktree layout matches repo …` means nothing did — +either there's no file for that repo, or its `repo`/`branch` didn't match. + +## See also + +- [Projects](../projects/) — the on-demand cousin: pick a project and spin up its + workspace by hand. +- [Configuration](../configuration/) — where herdr-plus config lives. +- [Troubleshooting](../troubleshooting/) — what to check when a layout doesn't fire.