From ae561124af6c5c20b550fb2a768a82de94036ae1 Mon Sep 17 00:00:00 2001 From: Spicer Matthews Date: Tue, 16 Jun 2026 09:25:29 -0700 Subject: [PATCH 1/3] Add worktree auto-layout (worktree.created event) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the worktree auto-layout feature forward onto the plugin architecture. When herdr creates a git worktree it makes a workspace and fires a worktree.created event; herdr-plus subscribes to it and lays a matching layout's tabs/panes into the new workspace automatically — extending Projects with event-driven automation. Each layout carries its own on/off switch: omit `enabled` (or set it true) to deploy, set `enabled = false` to keep the file on disk but stop it firing, so the worktree opens as a plain workspace. With no layout file at all, the feature is inert. - worktree.go: WorktreeLayout config (repo + optional branch matcher, plus the enabled *bool toggle, reusing the ProjectTab model), loader for ~/.config/herdr-plus/worktrees/, event-payload parsing, layout matching (branch-specific beats repo-only; disabled layouts skipped and never suppress an enabled match), and the handler. - herdr-plugin.toml: add an [[events]] entry on worktree.created. - main.go: dispatch the hidden on-worktree-created subcommand. - project.go: extract validateTabs, shared by Project and WorktreeLayout. - worktree_test.go: parse a real captured worktree.created payload; cover matching, validation, loading, and the enabled toggle. - Docs: README section, new www/ worktrees page, configuration, examples, index, and troubleshooting updates. --- README.md | 61 ++++++ herdr-plugin.toml | 10 + main.go | 5 + project.go | 19 +- worktree.go | 297 ++++++++++++++++++++++++++++ worktree_test.go | 260 ++++++++++++++++++++++++ www/content/docs/_index.md | 2 + www/content/docs/configuration.md | 11 +- www/content/docs/examples.md | 54 +++++ www/content/docs/troubleshooting.md | 22 +++ www/content/docs/worktrees.md | 136 +++++++++++++ 11 files changed, 871 insertions(+), 6 deletions(-) create mode 100644 worktree.go create mode 100644 worktree_test.go create mode 100644 www/content/docs/worktrees.md diff --git a/README.md b/README.md index 57abfc6..06ad798 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,67 @@ 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 feature is **opt-in**: with no files in `worktrees/`, it's inert — every +worktree fires the event, and herdr-plus does nothing when nothing matches. + +Each layout also carries its own on/off switch. Omit `enabled` (or set +`enabled = true`) and the layout deploys; set **`enabled = false`** to keep the +file on disk but stop it firing — creating a worktree of that repo then just makes +a plain workspace, exactly as if the file weren't there. Handy for pausing a layout +without deleting your tab list. + +```toml +repo = "options-cafe" +enabled = false # keep the layout, but don't deploy it + +[[tabs]] +name = "claude" +command = "claude" +``` + +The handler's output shows up in `herdr plugin log list --plugin +cloudmanic.herdr-plus` — including a line noting when a matching layout was skipped +because it's disabled, so it's easy to confirm the switch. + ## 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..2f6e6fd --- /dev/null +++ b/worktree.go @@ -0,0 +1,297 @@ +// +// 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"` + + // Enabled is the per-layout on/off switch. It is a pointer so an omitted + // `enabled` key is distinguishable from an explicit `enabled = false`: omitted + // (nil) means on, matching the rule that simply having a layout file turns the + // feature on. Set `enabled = false` to keep the layout on disk but stop it + // deploying — creating a worktree of its repo then just makes a plain + // workspace, as if no layout existed. + Enabled *bool `toml:"enabled"` + + // 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 +} + +// isEnabled reports whether the layout should deploy. A layout with no `enabled` +// key (Enabled == nil) is on by default — the presence of the file is the opt-in +// — so only an explicit `enabled = false` turns it off. +func (l WorktreeLayout) isEnabled() bool { + return l.Enabled == nil || *l.Enabled +} + +// 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. A +// disabled layout is still validated so a typo never hides behind `enabled = +// false`, surfacing only later when the layout is switched back on. +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. It does not consider the enabled switch — that is +// the caller's concern, so a disabled layout can still be recognized for logging. +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 enabled layout for an event, if any. +// Disabled layouts are skipped entirely — they neither deploy nor suppress an +// otherwise-matching enabled layout. Among the enabled matches 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.isEnabled() || !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 +} + +// disabledMatch returns a layout that would have matched the event but is turned +// off (enabled = false). It exists only so the handler can log the difference +// between "no layout for this repo" and "a layout exists but you switched it off" +// — a useful breadcrumb when a worktree opens plain and you expected tabs. +func disabledMatch(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLayout, bool) { + for _, l := range layouts { + if !l.isEnabled() && l.matches(ev) { + return l, true + } + } + return WorktreeLayout{}, false +} + +// 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 enabled layout for this repo/branch — the expected case for most + // worktrees. Distinguish "nothing configured" from "configured but switched + // off" so the plugin log explains why a worktree opened plain. + if off, disabled := disabledMatch(layouts, ev); disabled { + fmt.Printf("herdr-plus: worktree layout %q matches repo %q but is disabled (enabled = false); nothing to do.\n", off.source, ev.RepoName) + return + } + 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..01cf08e --- /dev/null +++ b/worktree_test.go @@ -0,0 +1,260 @@ +// +// 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] } +} + +// boolPtr returns a pointer to b, for setting the optional WorktreeLayout.Enabled +// field in tests (nil means "default on"). +func boolPtr(b bool) *bool { return &b } + +// 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") + } +} + +// TestWorktreeLayoutEnabledToggle covers the per-layout on/off switch: an absent +// `enabled` key defaults on, `enabled = false` is skipped by the matcher (and does +// not suppress an otherwise-matching enabled layout), and disabledMatch reports a +// switched-off layout for the handler's log message. +func TestWorktreeLayoutEnabledToggle(t *testing.T) { + ev, err := parseWorktreeEvent(realEventJSON, mapEnv(nil)) + if err != nil { + t.Fatalf("parseWorktreeEvent: %v", err) + } + + // isEnabled: nil defaults on; explicit true/false are honored. + if !(WorktreeLayout{}).isEnabled() { + t.Fatal("a layout with no enabled key should default to on") + } + if !(WorktreeLayout{Enabled: boolPtr(true)}).isEnabled() { + t.Fatal("enabled = true should be on") + } + if (WorktreeLayout{Enabled: boolPtr(false)}).isEnabled() { + t.Fatal("enabled = false should be off") + } + + off := WorktreeLayout{Repo: "wt-probe-repo", Enabled: boolPtr(false), Tabs: []ProjectTab{{Name: "x"}}, source: "off.toml"} + + // A disabled layout is not deployed... + if _, ok := matchWorktreeLayout([]WorktreeLayout{off}, ev); ok { + t.Fatal("a disabled layout should not be matched for deployment") + } + // ...but is reported as a disabled match so the handler can explain itself. + if got, ok := disabledMatch([]WorktreeLayout{off}, ev); !ok || got.source != "off.toml" { + t.Fatalf("disabledMatch = %q, %v; want off.toml, true", got.source, ok) + } + + // A disabled branch-specific layout must not suppress an enabled repo-only one. + on := WorktreeLayout{Repo: "wt-probe-repo", Tabs: []ProjectTab{{Name: "y"}}, source: "on.toml"} + offBranch := WorktreeLayout{Repo: "wt-probe-repo", Branch: "probe-wt", Enabled: boolPtr(false), Tabs: []ProjectTab{{Name: "z"}}, source: "offbranch.toml"} + if got, ok := matchWorktreeLayout([]WorktreeLayout{on, offBranch}, ev); !ok || got.source != "on.toml" { + t.Fatalf("match = %q, %v; want on.toml (disabled branch layout must not win or suppress)", got.source, ok) + } +} + +// 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). A +// disabled layout is still validated so a typo cannot hide behind enabled = false. +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}, + {"disabled still validated", WorktreeLayout{Repo: "r", Enabled: boolPtr(false)}, 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), a valid layout loads with its +// tabs intact, and an `enabled = false` key is parsed into the toggle. +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 layout that is explicitly switched off. + disabled := `repo = "bevio" +enabled = false + +[[tabs]] +name = "terminal" +` + if err := os.WriteFile(filepath.Join(dir, "bevio.toml"), []byte(disabled), 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" || got[0].isEnabled() { + t.Fatalf("loaded layout[0] = %+v, want bevio disabled", got[0]) + } + if got[1].Repo != "options-cafe" || got[1].Branch != "main" || len(got[1].Tabs) != 2 || !got[1].isEnabled() { + t.Fatalf("loaded layout[1] = %+v, want options-cafe/main, 2 tabs, enabled", 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..7f2c461 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, and set a + layout's `enabled = false` to keep it on disk without deploying it. ## 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..2819a33 100644 --- a/www/content/docs/examples.md +++ b/www/content/docs/examples.md @@ -254,8 +254,62 @@ 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" +``` + +### Pausing a layout without deleting it + +Set `enabled = false` to keep the file but stop it deploying — worktrees of that +repo then open as a plain workspace. + +```toml +# options-cafe.toml +repo = "options-cafe" +enabled = false # keep the tabs, just don't auto-deploy them + +[[tabs]] +name = "claude" +command = "claude" +``` + ## 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..5f72387 100644 --- a/www/content/docs/troubleshooting.md +++ b/www/content/docs/troubleshooting.md @@ -74,6 +74,28 @@ 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 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. Confirm the file is in `worktrees/` (not `projects/`). +- **The layout is switched off.** The log says `… matches repo … but is disabled`. + Remove `enabled = false` (or set it to `true`) in that layout file. +- **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..4ba1d08 --- /dev/null +++ b/www/content/docs/worktrees.md @@ -0,0 +1,136 @@ +--- +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 the feature on and off + +There are two layers of "on": + +1. **The directory itself is opt-in.** With no `worktrees/` directory — or an + empty one — herdr-plus does nothing. The event still fires for every worktree; + herdr-plus just has no layout to apply, so worktree creation is unchanged. + +2. **Each layout has its own switch.** A layout deploys when `enabled` is omitted + or set to `true`. Set **`enabled = false`** to keep the file on disk but stop it + firing — creating a worktree of that repo then just makes a plain workspace, + exactly as if the layout weren't there. It's the clean way to pause a layout + without deleting your tab list. + +```toml +repo = "options-cafe" +enabled = false # keep the layout, but don't deploy it + +[[tabs]] +name = "claude" +command = "claude" +``` + +A disabled layout is still validated on load (so a typo can't hide behind +`enabled = false`), and it never suppresses another matching layout — if you have +an enabled repo-only layout and a disabled branch-specific one, the enabled one +still wins. + +## 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 enabled 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 enabled 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, +and `worktree layout "options-cafe.toml" matches repo "options-cafe" but is +disabled` means a layout matched but you switched it off. + +## 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. From 577c58ffa1cbcc9a8b7eb6681b4fc3016adf88ac Mon Sep 17 00:00:00 2001 From: Spicer Matthews Date: Tue, 16 Jun 2026 09:34:30 -0700 Subject: [PATCH 2/3] Rename the layout toggle to on_worktree_created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-layout on/off switch was `enabled`, which doesn't say what it gates. A worktree layout only ever does one thing — open when herdr fires worktree.created — so name the key after that event. Renames the TOML key, the struct field (OnWorktreeCreated), the appliesOnCreate predicate, the switchedOffMatch helper, the plugin-log message, and every doc reference. --- README.md | 15 ++++--- worktree.go | 62 +++++++++++++------------- worktree_test.go | 67 +++++++++++++++-------------- www/content/docs/configuration.md | 2 +- www/content/docs/examples.md | 6 +-- www/content/docs/troubleshooting.md | 5 ++- www/content/docs/worktrees.md | 29 +++++++------ 7 files changed, 97 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 06ad798..97dcb51 100644 --- a/README.md +++ b/README.md @@ -211,15 +211,16 @@ name = "terminal" # no command — just an empty shell The feature is **opt-in**: with no files in `worktrees/`, it's inert — every worktree fires the event, and herdr-plus does nothing when nothing matches. -Each layout also carries its own on/off switch. Omit `enabled` (or set -`enabled = true`) and the layout deploys; set **`enabled = false`** to keep the -file on disk but stop it firing — creating a worktree of that repo then just makes -a plain workspace, exactly as if the file weren't there. Handy for pausing a layout -without deleting your tab list. +Each layout also carries its own on/off switch, named after the event it gates. +Omit `on_worktree_created` (or set it `true`) and the layout opens on worktree +creation; set **`on_worktree_created = false`** to keep the file on disk but stop +it firing — creating a worktree of that repo then just makes a plain workspace, +exactly as if the file weren't there. Handy for pausing a layout without deleting +your tab list. ```toml repo = "options-cafe" -enabled = false # keep the layout, but don't deploy it +on_worktree_created = false # keep the layout, but don't open it [[tabs]] name = "claude" @@ -228,7 +229,7 @@ command = "claude" The handler's output shows up in `herdr plugin log list --plugin cloudmanic.herdr-plus` — including a line noting when a matching layout was skipped -because it's disabled, so it's easy to confirm the switch. +because it's switched off, so it's easy to confirm the switch. ## Binding a key diff --git a/worktree.go b/worktree.go index 2f6e6fd..bf3cb9f 100644 --- a/worktree.go +++ b/worktree.go @@ -37,13 +37,15 @@ type WorktreeLayout struct { // branch-specific layout is preferred over a repo-only one when both match. Branch string `toml:"branch"` - // Enabled is the per-layout on/off switch. It is a pointer so an omitted - // `enabled` key is distinguishable from an explicit `enabled = false`: omitted - // (nil) means on, matching the rule that simply having a layout file turns the - // feature on. Set `enabled = false` to keep the layout on disk but stop it - // deploying — creating a worktree of its repo then just makes a plain - // workspace, as if no layout existed. - Enabled *bool `toml:"enabled"` + // OnWorktreeCreated is the per-layout on/off switch for the only thing a layout + // does: open itself when herdr fires worktree.created. It is named after that + // event so the key says exactly what it gates. It is a pointer so an omitted + // key is distinguishable from an explicit `on_worktree_created = false`: + // omitted (nil) means on, matching the rule that simply having a layout file + // turns the feature on. Set `on_worktree_created = false` to keep the layout on + // disk but stop it opening — creating a worktree of its repo then just makes a + // plain workspace, as if no layout existed. + OnWorktreeCreated *bool `toml:"on_worktree_created"` // 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 @@ -55,17 +57,18 @@ type WorktreeLayout struct { source string } -// isEnabled reports whether the layout should deploy. A layout with no `enabled` -// key (Enabled == nil) is on by default — the presence of the file is the opt-in -// — so only an explicit `enabled = false` turns it off. -func (l WorktreeLayout) isEnabled() bool { - return l.Enabled == nil || *l.Enabled +// appliesOnCreate reports whether the layout should open when a worktree is +// created. A layout with no `on_worktree_created` key (the field is nil) is on by +// default — the presence of the file is the opt-in — so only an explicit +// `on_worktree_created = false` turns it off. +func (l WorktreeLayout) appliesOnCreate() bool { + return l.OnWorktreeCreated == nil || *l.OnWorktreeCreated } // 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. A -// disabled layout is still validated so a typo never hides behind `enabled = -// false`, surfacing only later when the layout is switched back on. +// switched-off layout is still validated so a typo never hides behind +// `on_worktree_created = false`, surfacing only later when it is switched back on. func (l WorktreeLayout) validate() error { if strings.TrimSpace(l.Repo) == "" { return fmt.Errorf("worktree layout %s: repo is required", l.source) @@ -79,8 +82,8 @@ func (l WorktreeLayout) validate() error { // 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. It does not consider the enabled switch — that is -// the caller's concern, so a disabled layout can still be recognized for logging. +// worktree's branch to match. It does not consider the on/off switch — that is +// the caller's concern, so a switched-off layout can still be recognized for logging. 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)) { @@ -152,16 +155,16 @@ func loadWorktreeLayouts() ([]WorktreeLayout, error) { return layouts, nil } -// matchWorktreeLayout returns the best enabled layout for an event, if any. -// Disabled layouts are skipped entirely — they neither deploy nor suppress an -// otherwise-matching enabled layout. Among the enabled matches a branch-specific +// matchWorktreeLayout returns the best applicable layout for an event, if any. +// Switched-off layouts are skipped entirely — they neither open nor suppress an +// otherwise-matching active layout. Among the active matches 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.isEnabled() || !l.matches(ev) { + if !l.appliesOnCreate() || !l.matches(ev) { continue } if !found { @@ -176,13 +179,14 @@ func matchWorktreeLayout(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLa return best, found } -// disabledMatch returns a layout that would have matched the event but is turned -// off (enabled = false). It exists only so the handler can log the difference -// between "no layout for this repo" and "a layout exists but you switched it off" -// — a useful breadcrumb when a worktree opens plain and you expected tabs. -func disabledMatch(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLayout, bool) { +// switchedOffMatch returns a layout that would have matched the event but is +// turned off (on_worktree_created = false). It exists only so the handler can log +// the difference between "no layout for this repo" and "a layout exists but you +// switched it off" — a useful breadcrumb when a worktree opens plain and you +// expected tabs. +func switchedOffMatch(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLayout, bool) { for _, l := range layouts { - if !l.isEnabled() && l.matches(ev) { + if !l.appliesOnCreate() && l.matches(ev) { return l, true } } @@ -266,11 +270,11 @@ func runOnWorktreeCreated(_ []string) { layout, ok := matchWorktreeLayout(layouts, ev) if !ok { - // No enabled layout for this repo/branch — the expected case for most + // No applicable layout for this repo/branch — the expected case for most // worktrees. Distinguish "nothing configured" from "configured but switched // off" so the plugin log explains why a worktree opened plain. - if off, disabled := disabledMatch(layouts, ev); disabled { - fmt.Printf("herdr-plus: worktree layout %q matches repo %q but is disabled (enabled = false); nothing to do.\n", off.source, ev.RepoName) + if off, switchedOff := switchedOffMatch(layouts, ev); switchedOff { + fmt.Printf("herdr-plus: worktree layout %q matches repo %q but on_worktree_created = false; nothing to do.\n", off.source, ev.RepoName) return } fmt.Printf("herdr-plus: no worktree layout matches repo %q (branch %q); nothing to do.\n", ev.RepoName, ev.Branch) diff --git a/worktree_test.go b/worktree_test.go index 01cf08e..cb7d8ab 100644 --- a/worktree_test.go +++ b/worktree_test.go @@ -23,8 +23,8 @@ func mapEnv(m map[string]string) func(string) string { return func(k string) string { return m[k] } } -// boolPtr returns a pointer to b, for setting the optional WorktreeLayout.Enabled -// field in tests (nil means "default on"). +// boolPtr returns a pointer to b, for setting the optional +// WorktreeLayout.OnWorktreeCreated field in tests (nil means "default on"). func boolPtr(b bool) *bool { return &b } // TestParseWorktreeEventReal parses the real payload plus the HERDR_* env vars @@ -128,49 +128,50 @@ func TestMatchWorktreeLayout(t *testing.T) { } } -// TestWorktreeLayoutEnabledToggle covers the per-layout on/off switch: an absent -// `enabled` key defaults on, `enabled = false` is skipped by the matcher (and does -// not suppress an otherwise-matching enabled layout), and disabledMatch reports a -// switched-off layout for the handler's log message. -func TestWorktreeLayoutEnabledToggle(t *testing.T) { +// TestWorktreeLayoutOnCreateToggle covers the per-layout on/off switch: an absent +// `on_worktree_created` key defaults on, `on_worktree_created = false` is skipped +// by the matcher (and does not suppress an otherwise-matching active layout), and +// switchedOffMatch reports a switched-off layout for the handler's log message. +func TestWorktreeLayoutOnCreateToggle(t *testing.T) { ev, err := parseWorktreeEvent(realEventJSON, mapEnv(nil)) if err != nil { t.Fatalf("parseWorktreeEvent: %v", err) } - // isEnabled: nil defaults on; explicit true/false are honored. - if !(WorktreeLayout{}).isEnabled() { - t.Fatal("a layout with no enabled key should default to on") + // appliesOnCreate: nil defaults on; explicit true/false are honored. + if !(WorktreeLayout{}).appliesOnCreate() { + t.Fatal("a layout with no on_worktree_created key should default to on") } - if !(WorktreeLayout{Enabled: boolPtr(true)}).isEnabled() { - t.Fatal("enabled = true should be on") + if !(WorktreeLayout{OnWorktreeCreated: boolPtr(true)}).appliesOnCreate() { + t.Fatal("on_worktree_created = true should be on") } - if (WorktreeLayout{Enabled: boolPtr(false)}).isEnabled() { - t.Fatal("enabled = false should be off") + if (WorktreeLayout{OnWorktreeCreated: boolPtr(false)}).appliesOnCreate() { + t.Fatal("on_worktree_created = false should be off") } - off := WorktreeLayout{Repo: "wt-probe-repo", Enabled: boolPtr(false), Tabs: []ProjectTab{{Name: "x"}}, source: "off.toml"} + off := WorktreeLayout{Repo: "wt-probe-repo", OnWorktreeCreated: boolPtr(false), Tabs: []ProjectTab{{Name: "x"}}, source: "off.toml"} - // A disabled layout is not deployed... + // A switched-off layout is not opened... if _, ok := matchWorktreeLayout([]WorktreeLayout{off}, ev); ok { - t.Fatal("a disabled layout should not be matched for deployment") + t.Fatal("a switched-off layout should not be matched for deployment") } - // ...but is reported as a disabled match so the handler can explain itself. - if got, ok := disabledMatch([]WorktreeLayout{off}, ev); !ok || got.source != "off.toml" { - t.Fatalf("disabledMatch = %q, %v; want off.toml, true", got.source, ok) + // ...but is reported as a switched-off match so the handler can explain itself. + if got, ok := switchedOffMatch([]WorktreeLayout{off}, ev); !ok || got.source != "off.toml" { + t.Fatalf("switchedOffMatch = %q, %v; want off.toml, true", got.source, ok) } - // A disabled branch-specific layout must not suppress an enabled repo-only one. + // A switched-off branch-specific layout must not suppress an active repo-only one. on := WorktreeLayout{Repo: "wt-probe-repo", Tabs: []ProjectTab{{Name: "y"}}, source: "on.toml"} - offBranch := WorktreeLayout{Repo: "wt-probe-repo", Branch: "probe-wt", Enabled: boolPtr(false), Tabs: []ProjectTab{{Name: "z"}}, source: "offbranch.toml"} + offBranch := WorktreeLayout{Repo: "wt-probe-repo", Branch: "probe-wt", OnWorktreeCreated: boolPtr(false), Tabs: []ProjectTab{{Name: "z"}}, source: "offbranch.toml"} if got, ok := matchWorktreeLayout([]WorktreeLayout{on, offBranch}, ev); !ok || got.source != "on.toml" { - t.Fatalf("match = %q, %v; want on.toml (disabled branch layout must not win or suppress)", got.source, ok) + t.Fatalf("match = %q, %v; want on.toml (switched-off branch layout must not win or suppress)", got.source, ok) } } // 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). A -// disabled layout is still validated so a typo cannot hide behind enabled = false. +// switched-off layout is still validated so a typo cannot hide behind +// on_worktree_created = false. func TestWorktreeLayoutValidate(t *testing.T) { cases := []struct { name string @@ -181,7 +182,7 @@ func TestWorktreeLayoutValidate(t *testing.T) { {"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}, - {"disabled still validated", WorktreeLayout{Repo: "r", Enabled: boolPtr(false)}, true}, + {"switched off still validated", WorktreeLayout{Repo: "r", OnWorktreeCreated: boolPtr(false)}, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -194,7 +195,7 @@ func TestWorktreeLayoutValidate(t *testing.T) { // TestLoadWorktreeLayouts round-trips real files through the loader: a missing // directory yields nothing (the feature is opt-in), a valid layout loads with its -// tabs intact, and an `enabled = false` key is parsed into the toggle. +// tabs intact, and an `on_worktree_created = false` key is parsed into the toggle. func TestLoadWorktreeLayouts(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp) @@ -225,13 +226,13 @@ command = "lazygit" } // A second layout that is explicitly switched off. - disabled := `repo = "bevio" -enabled = false + switchedOff := `repo = "bevio" +on_worktree_created = false [[tabs]] name = "terminal" ` - if err := os.WriteFile(filepath.Join(dir, "bevio.toml"), []byte(disabled), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "bevio.toml"), []byte(switchedOff), 0o644); err != nil { t.Fatal(err) } @@ -243,11 +244,11 @@ name = "terminal" t.Fatalf("got %d layouts, want 2", len(got)) } // Sorted by file name: bevio.toml then options-cafe.toml. - if got[0].Repo != "bevio" || got[0].isEnabled() { - t.Fatalf("loaded layout[0] = %+v, want bevio disabled", got[0]) + if got[0].Repo != "bevio" || got[0].appliesOnCreate() { + t.Fatalf("loaded layout[0] = %+v, want bevio switched off", got[0]) } - if got[1].Repo != "options-cafe" || got[1].Branch != "main" || len(got[1].Tabs) != 2 || !got[1].isEnabled() { - t.Fatalf("loaded layout[1] = %+v, want options-cafe/main, 2 tabs, enabled", got[1]) + if got[1].Repo != "options-cafe" || got[1].Branch != "main" || len(got[1].Tabs) != 2 || !got[1].appliesOnCreate() { + t.Fatalf("loaded layout[1] = %+v, want options-cafe/main, 2 tabs, on", got[1]) } // A malformed file fails the whole load with a naming error. diff --git a/www/content/docs/configuration.md b/www/content/docs/configuration.md index 7f2c461..6400fa9 100644 --- a/www/content/docs/configuration.md +++ b/www/content/docs/configuration.md @@ -50,7 +50,7 @@ upgrade**, so your projects and quick actions survive a reinstall. - **`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, and set a - layout's `enabled = false` to keep it on disk without deploying it. + layout's `on_worktree_created = false` to keep it on disk without firing it. ## The file-per-entry model diff --git a/www/content/docs/examples.md b/www/content/docs/examples.md index 2819a33..3739b68 100644 --- a/www/content/docs/examples.md +++ b/www/content/docs/examples.md @@ -294,13 +294,13 @@ command = "./scripts/release.sh" ### Pausing a layout without deleting it -Set `enabled = false` to keep the file but stop it deploying — worktrees of that -repo then open as a plain workspace. +Set `on_worktree_created = false` to keep the file but stop it firing — worktrees +of that repo then open as a plain workspace. ```toml # options-cafe.toml repo = "options-cafe" -enabled = false # keep the tabs, just don't auto-deploy them +on_worktree_created = false # keep the tabs, just don't auto-open them [[tabs]] name = "claude" diff --git a/www/content/docs/troubleshooting.md b/www/content/docs/troubleshooting.md index 5f72387..397d954 100644 --- a/www/content/docs/troubleshooting.md +++ b/www/content/docs/troubleshooting.md @@ -89,8 +89,9 @@ Common causes: - **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. Confirm the file is in `worktrees/` (not `projects/`). -- **The layout is switched off.** The log says `… matches repo … but is disabled`. - Remove `enabled = false` (or set it to `true`) in that layout file. +- **The layout is switched off.** The log says `… matches repo … but + on_worktree_created = false`. Remove `on_worktree_created = false` (or set it to + `true`) in that layout file. - **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 diff --git a/www/content/docs/worktrees.md b/www/content/docs/worktrees.md index 4ba1d08..bb5f73f 100644 --- a/www/content/docs/worktrees.md +++ b/www/content/docs/worktrees.md @@ -58,25 +58,26 @@ There are two layers of "on": empty one — herdr-plus does nothing. The event still fires for every worktree; herdr-plus just has no layout to apply, so worktree creation is unchanged. -2. **Each layout has its own switch.** A layout deploys when `enabled` is omitted - or set to `true`. Set **`enabled = false`** to keep the file on disk but stop it - firing — creating a worktree of that repo then just makes a plain workspace, - exactly as if the layout weren't there. It's the clean way to pause a layout - without deleting your tab list. +2. **Each layout has its own switch,** named after the event it gates. A layout + opens when `on_worktree_created` is omitted or set to `true`. Set + **`on_worktree_created = false`** to keep the file on disk but stop it firing — + creating a worktree of that repo then just makes a plain workspace, exactly as + if the layout weren't there. It's the clean way to pause a layout without + deleting your tab list. ```toml repo = "options-cafe" -enabled = false # keep the layout, but don't deploy it +on_worktree_created = false # keep the layout, but don't open it [[tabs]] name = "claude" command = "claude" ``` -A disabled layout is still validated on load (so a typo can't hide behind -`enabled = false`), and it never suppresses another matching layout — if you have -an enabled repo-only layout and a disabled branch-specific one, the enabled one -still wins. +A switched-off layout is still validated on load (so a typo can't hide behind +`on_worktree_created = false`), and it never suppresses another matching layout — +if you have an active repo-only layout and a switched-off branch-specific one, the +active one still wins. ## Matching rules @@ -86,7 +87,7 @@ still wins. 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 enabled layout matches** the same worktree, a +- When **more than one active layout matches** the same worktree, a branch-specific layout wins over a repo-only one; otherwise the first by file name is used. @@ -109,7 +110,7 @@ for the full vocabulary. ## When nothing matches -Every worktree creation fires the event, but if no enabled layout matches the +Every worktree creation fires the event, but if no active 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. @@ -125,8 +126,8 @@ 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, -and `worktree layout "options-cafe.toml" matches repo "options-cafe" but is -disabled` means a layout matched but you switched it off. +and `worktree layout "options-cafe.toml" matches repo "options-cafe" but +on_worktree_created = false` means a layout matched but you switched it off. ## See also From 174fe959689a6b0aa8c7c0bde29e23d8ad6603a5 Mon Sep 17 00:00:00 2001 From: Spicer Matthews Date: Tue, 16 Jun 2026 09:46:49 -0700 Subject: [PATCH 3/3] Drop the per-layout toggle; file presence is the switch A worktree layout is on simply by existing in worktrees/; to turn one off, delete the file. That removes the need for a separate on/off key to keep in sync, so the on_worktree_created field is gone along with its predicate, the switched-off log path, and the tests/docs that covered it. --- README.md | 25 +++-------- worktree.go | 60 +++++-------------------- worktree_test.go | 68 +++++------------------------ www/content/docs/configuration.md | 4 +- www/content/docs/examples.md | 15 ------- www/content/docs/troubleshooting.md | 8 ++-- www/content/docs/worktrees.md | 51 ++++++++-------------- 7 files changed, 48 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 97dcb51..d801667 100644 --- a/README.md +++ b/README.md @@ -208,28 +208,13 @@ name = "terminal" # no command — just an empty shell ### Turning a layout on and off -The feature is **opt-in**: with no files in `worktrees/`, it's inert — every -worktree fires the event, and herdr-plus does nothing when nothing matches. - -Each layout also carries its own on/off switch, named after the event it gates. -Omit `on_worktree_created` (or set it `true`) and the layout opens on worktree -creation; set **`on_worktree_created = false`** to keep the file on disk but stop -it firing — creating a worktree of that repo then just makes a plain workspace, -exactly as if the file weren't there. Handy for pausing a layout without deleting -your tab list. - -```toml -repo = "options-cafe" -on_worktree_created = false # keep the layout, but don't open it - -[[tabs]] -name = "claude" -command = "claude" -``` +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` — including a line noting when a matching layout was skipped -because it's switched off, so it's easy to confirm the switch. +cloudmanic.herdr-plus`, so you can confirm whether a layout fired. ## Binding a key diff --git a/worktree.go b/worktree.go index bf3cb9f..2013b0d 100644 --- a/worktree.go +++ b/worktree.go @@ -37,16 +37,6 @@ type WorktreeLayout struct { // branch-specific layout is preferred over a repo-only one when both match. Branch string `toml:"branch"` - // OnWorktreeCreated is the per-layout on/off switch for the only thing a layout - // does: open itself when herdr fires worktree.created. It is named after that - // event so the key says exactly what it gates. It is a pointer so an omitted - // key is distinguishable from an explicit `on_worktree_created = false`: - // omitted (nil) means on, matching the rule that simply having a layout file - // turns the feature on. Set `on_worktree_created = false` to keep the layout on - // disk but stop it opening — creating a worktree of its repo then just makes a - // plain workspace, as if no layout existed. - OnWorktreeCreated *bool `toml:"on_worktree_created"` - // 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). @@ -57,18 +47,8 @@ type WorktreeLayout struct { source string } -// appliesOnCreate reports whether the layout should open when a worktree is -// created. A layout with no `on_worktree_created` key (the field is nil) is on by -// default — the presence of the file is the opt-in — so only an explicit -// `on_worktree_created = false` turns it off. -func (l WorktreeLayout) appliesOnCreate() bool { - return l.OnWorktreeCreated == nil || *l.OnWorktreeCreated -} - // 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. A -// switched-off layout is still validated so a typo never hides behind -// `on_worktree_created = false`, surfacing only later when it is switched back on. +// 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) @@ -82,8 +62,7 @@ func (l WorktreeLayout) validate() error { // 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. It does not consider the on/off switch — that is -// the caller's concern, so a switched-off layout can still be recognized for logging. +// 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)) { @@ -155,16 +134,15 @@ func loadWorktreeLayouts() ([]WorktreeLayout, error) { return layouts, nil } -// matchWorktreeLayout returns the best applicable layout for an event, if any. -// Switched-off layouts are skipped entirely — they neither open nor suppress an -// otherwise-matching active layout. Among the active matches a branch-specific -// one wins over a repo-only one (it is more specific); ties break by file name, -// which loadWorktreeLayouts already sorts by. +// 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.appliesOnCreate() || !l.matches(ev) { + if !l.matches(ev) { continue } if !found { @@ -179,20 +157,6 @@ func matchWorktreeLayout(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLa return best, found } -// switchedOffMatch returns a layout that would have matched the event but is -// turned off (on_worktree_created = false). It exists only so the handler can log -// the difference between "no layout for this repo" and "a layout exists but you -// switched it off" — a useful breadcrumb when a worktree opens plain and you -// expected tabs. -func switchedOffMatch(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLayout, bool) { - for _, l := range layouts { - if !l.appliesOnCreate() && l.matches(ev) { - return l, true - } - } - return WorktreeLayout{}, false -} - // 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 @@ -270,13 +234,9 @@ func runOnWorktreeCreated(_ []string) { layout, ok := matchWorktreeLayout(layouts, ev) if !ok { - // No applicable layout for this repo/branch — the expected case for most - // worktrees. Distinguish "nothing configured" from "configured but switched - // off" so the plugin log explains why a worktree opened plain. - if off, switchedOff := switchedOffMatch(layouts, ev); switchedOff { - fmt.Printf("herdr-plus: worktree layout %q matches repo %q but on_worktree_created = false; nothing to do.\n", off.source, ev.RepoName) - return - } + // 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 } diff --git a/worktree_test.go b/worktree_test.go index cb7d8ab..a2dfe4a 100644 --- a/worktree_test.go +++ b/worktree_test.go @@ -23,10 +23,6 @@ func mapEnv(m map[string]string) func(string) string { return func(k string) string { return m[k] } } -// boolPtr returns a pointer to b, for setting the optional -// WorktreeLayout.OnWorktreeCreated field in tests (nil means "default on"). -func boolPtr(b bool) *bool { return &b } - // 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) { @@ -128,50 +124,8 @@ func TestMatchWorktreeLayout(t *testing.T) { } } -// TestWorktreeLayoutOnCreateToggle covers the per-layout on/off switch: an absent -// `on_worktree_created` key defaults on, `on_worktree_created = false` is skipped -// by the matcher (and does not suppress an otherwise-matching active layout), and -// switchedOffMatch reports a switched-off layout for the handler's log message. -func TestWorktreeLayoutOnCreateToggle(t *testing.T) { - ev, err := parseWorktreeEvent(realEventJSON, mapEnv(nil)) - if err != nil { - t.Fatalf("parseWorktreeEvent: %v", err) - } - - // appliesOnCreate: nil defaults on; explicit true/false are honored. - if !(WorktreeLayout{}).appliesOnCreate() { - t.Fatal("a layout with no on_worktree_created key should default to on") - } - if !(WorktreeLayout{OnWorktreeCreated: boolPtr(true)}).appliesOnCreate() { - t.Fatal("on_worktree_created = true should be on") - } - if (WorktreeLayout{OnWorktreeCreated: boolPtr(false)}).appliesOnCreate() { - t.Fatal("on_worktree_created = false should be off") - } - - off := WorktreeLayout{Repo: "wt-probe-repo", OnWorktreeCreated: boolPtr(false), Tabs: []ProjectTab{{Name: "x"}}, source: "off.toml"} - - // A switched-off layout is not opened... - if _, ok := matchWorktreeLayout([]WorktreeLayout{off}, ev); ok { - t.Fatal("a switched-off layout should not be matched for deployment") - } - // ...but is reported as a switched-off match so the handler can explain itself. - if got, ok := switchedOffMatch([]WorktreeLayout{off}, ev); !ok || got.source != "off.toml" { - t.Fatalf("switchedOffMatch = %q, %v; want off.toml, true", got.source, ok) - } - - // A switched-off branch-specific layout must not suppress an active repo-only one. - on := WorktreeLayout{Repo: "wt-probe-repo", Tabs: []ProjectTab{{Name: "y"}}, source: "on.toml"} - offBranch := WorktreeLayout{Repo: "wt-probe-repo", Branch: "probe-wt", OnWorktreeCreated: boolPtr(false), Tabs: []ProjectTab{{Name: "z"}}, source: "offbranch.toml"} - if got, ok := matchWorktreeLayout([]WorktreeLayout{on, offBranch}, ev); !ok || got.source != "on.toml" { - t.Fatalf("match = %q, %v; want on.toml (switched-off branch layout must not win or suppress)", got.source, ok) - } -} - // 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). A -// switched-off layout is still validated so a typo cannot hide behind -// on_worktree_created = false. +// and that it inherits the shared per-tab validation (a bad split is rejected). func TestWorktreeLayoutValidate(t *testing.T) { cases := []struct { name string @@ -182,7 +136,6 @@ func TestWorktreeLayoutValidate(t *testing.T) { {"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}, - {"switched off still validated", WorktreeLayout{Repo: "r", OnWorktreeCreated: boolPtr(false)}, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -194,8 +147,8 @@ func TestWorktreeLayoutValidate(t *testing.T) { } // TestLoadWorktreeLayouts round-trips real files through the loader: a missing -// directory yields nothing (the feature is opt-in), a valid layout loads with its -// tabs intact, and an `on_worktree_created = false` key is parsed into the toggle. +// 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) @@ -225,14 +178,13 @@ command = "lazygit" t.Fatal(err) } - // A second layout that is explicitly switched off. - switchedOff := `repo = "bevio" -on_worktree_created = false + // 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(switchedOff), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "bevio.toml"), []byte(second), 0o644); err != nil { t.Fatal(err) } @@ -244,11 +196,11 @@ name = "terminal" t.Fatalf("got %d layouts, want 2", len(got)) } // Sorted by file name: bevio.toml then options-cafe.toml. - if got[0].Repo != "bevio" || got[0].appliesOnCreate() { - t.Fatalf("loaded layout[0] = %+v, want bevio switched off", got[0]) + 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 || !got[1].appliesOnCreate() { - t.Fatalf("loaded layout[1] = %+v, want options-cafe/main, 2 tabs, on", got[1]) + 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. diff --git a/www/content/docs/configuration.md b/www/content/docs/configuration.md index 6400fa9..fc008b7 100644 --- a/www/content/docs/configuration.md +++ b/www/content/docs/configuration.md @@ -49,8 +49,8 @@ upgrade**, so your projects and quick actions survive a reinstall. 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, and set a - layout's `on_worktree_created = false` to keep it on disk without firing it. + 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 diff --git a/www/content/docs/examples.md b/www/content/docs/examples.md index 3739b68..45abf31 100644 --- a/www/content/docs/examples.md +++ b/www/content/docs/examples.md @@ -292,21 +292,6 @@ name = "deploy" command = "./scripts/release.sh" ``` -### Pausing a layout without deleting it - -Set `on_worktree_created = false` to keep the file but stop it firing — worktrees -of that repo then open as a plain workspace. - -```toml -# options-cafe.toml -repo = "options-cafe" -on_worktree_created = false # keep the tabs, just don't auto-open them - -[[tabs]] -name = "claude" -command = "claude" -``` - ## See also - [Actions Reference](../actions/) — the full action format. diff --git a/www/content/docs/troubleshooting.md b/www/content/docs/troubleshooting.md index 397d954..7a04486 100644 --- a/www/content/docs/troubleshooting.md +++ b/www/content/docs/troubleshooting.md @@ -86,12 +86,12 @@ 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. Confirm the file is in `worktrees/` (not `projects/`). -- **The layout is switched off.** The log says `… matches repo … but - on_worktree_created = false`. Remove `on_worktree_created = false` (or set it to - `true`) in that layout file. + 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 diff --git a/www/content/docs/worktrees.md b/www/content/docs/worktrees.md index bb5f73f..9474aab 100644 --- a/www/content/docs/worktrees.md +++ b/www/content/docs/worktrees.md @@ -50,34 +50,19 @@ 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 the feature on and off +## Turning a layout on and off -There are two layers of "on": +The switch is simply **whether the file exists**: -1. **The directory itself is opt-in.** With no `worktrees/` directory — or an - empty one — herdr-plus does nothing. The event still fires for every worktree; - herdr-plus just has no layout to apply, so worktree creation is unchanged. +- **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. -2. **Each layout has its own switch,** named after the event it gates. A layout - opens when `on_worktree_created` is omitted or set to `true`. Set - **`on_worktree_created = false`** to keep the file on disk but stop it firing — - creating a worktree of that repo then just makes a plain workspace, exactly as - if the layout weren't there. It's the clean way to pause a layout without - deleting your tab list. - -```toml -repo = "options-cafe" -on_worktree_created = false # keep the layout, but don't open it - -[[tabs]] -name = "claude" -command = "claude" -``` - -A switched-off layout is still validated on load (so a typo can't hide behind -`on_worktree_created = false`), and it never suppresses another matching layout — -if you have an active repo-only layout and a switched-off branch-specific one, the -active one still wins. +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 @@ -87,9 +72,8 @@ active one still wins. 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 active layout matches** the same worktree, a - branch-specific layout wins over a repo-only one; otherwise the first by file - name is used. +- 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. @@ -110,9 +94,9 @@ for the full vocabulary. ## When nothing matches -Every worktree creation fires the event, but if no active 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. +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 @@ -125,9 +109,8 @@ 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, -and `worktree layout "options-cafe.toml" matches repo "options-cafe" but -on_worktree_created = false` means a layout matched but you switched it off. +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