diff --git a/README.md b/README.md index d801667..7632a0b 100644 --- a/README.md +++ b/README.md @@ -172,12 +172,13 @@ as `HERDR_PLUS_*` environment variables. If a command doesn't reference ## 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. +herdr creates *or opens* it. When you run `herdr worktree create`/`open` (or use +herdr's right-click worktree dialog), herdr makes a fresh workspace for the +worktree and fires a `worktree.created` event (new worktree) or `worktree.opened` +event (existing one); herdr-plus catches either, 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]]` diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 8169ea9..7f1680a 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -72,12 +72,20 @@ 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. +# worktree.created / worktree.opened — herdr fires worktree.created when it +# creates a new git worktree and worktree.opened when it opens an existing one +# into a workspace (e.g. the right-click dialog for a branch that already has a +# worktree). Both hand us a fresh workspace that wants tabs, so we subscribe to +# both — otherwise opening an existing worktree would silently skip the layout. +# The handler looks for a matching layout in ~/.config/herdr-plus/worktrees/ and +# lays its tabs/panes into the workspace. It is a quiet no-op when no layout +# matches, and it skips a workspace that already has tabs (so a layout never +# applies twice even if both events fire). 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"] +command = ["./bin/herdr-plus", "on-worktree"] + +[[events]] +on = "worktree.opened" +command = ["./bin/herdr-plus", "on-worktree"] diff --git a/herdr.go b/herdr.go index 10364ec..39ce705 100644 --- a/herdr.go +++ b/herdr.go @@ -272,6 +272,30 @@ func (c *herdrClient) focusedPaneID() (string, error) { return "", errors.New("no focused pane") } +// workspacePaneCount returns how many panes currently live in the given +// workspace. The worktree handler uses it as an idempotency guard: a freshly +// created or opened worktree workspace has exactly one (root) pane, so a count +// above one means the layout was already applied — and we should not apply it +// again. On any socket error it returns 0 so the caller fails open (proceeds) and +// degrades to the old, unguarded behavior rather than skipping wrongly. +func (c *herdrClient) workspacePaneCount(workspaceID string) (int, error) { + var out struct { + Panes []struct { + WorkspaceID string `json:"workspace_id"` + } `json:"panes"` + } + if err := c.call("pane.list", map[string]any{}, &out); err != nil { + return 0, err + } + n := 0 + for _, p := range out.Panes { + if p.WorkspaceID == workspaceID { + n++ + } + } + return n, nil +} + // paneGet fetches metadata for a single pane, including its working directory // and the tab/workspace it belongs to. func (c *herdrClient) paneGet(paneID string) (paneInfo, error) { diff --git a/main.go b/main.go index 36c95b7..0f2a22b 100644 --- a/main.go +++ b/main.go @@ -23,8 +23,9 @@ 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. +// - "on-worktree" is herdr's worktree event handler, run for both worktree.created +// and worktree.opened (via the [[events]] entries in herdr-plugin.toml), not the +// user. // // The bare binary has no launcher of its own, so it just prints usage. func main() { @@ -45,8 +46,8 @@ func main() { case "ping": runPing() return - case "on-worktree-created": - runOnWorktreeCreated(os.Args[2:]) + case "on-worktree": + runOnWorktreeEvent(os.Args[2:]) return case "version", "--version", "-v", "-V": fmt.Println("herdr-plus", version.Version) diff --git a/worktree.go b/worktree.go index 2013b0d..e9f99f1 100644 --- a/worktree.go +++ b/worktree.go @@ -18,11 +18,12 @@ import ( ) // 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. +// git worktree is created or opened. Where a Project is opened on demand and +// creates its own workspace, a worktree layout reacts to herdr's worktree.created +// and worktree.opened events: 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. @@ -172,7 +173,8 @@ type worktreeEvent struct { } // worktreeCreatedPayload mirrors the JSON herdr puts in HERDR_PLUGIN_EVENT_JSON -// for a `worktree.created` event. Only the fields we use are declared. +// for a worktree.created or worktree.opened event (the two share this shape). +// Only the fields we use are declared. type worktreeCreatedPayload struct { Data struct { Workspace struct { @@ -214,14 +216,16 @@ func parseWorktreeEvent(eventJSON string, getenv func(string) string) (worktreeE }, 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) { +// runOnWorktreeEvent is the worktree event handler herdr invokes (via the +// [[events]] entries in herdr-plugin.toml). herdr runs it for both +// worktree.created (a new worktree) and worktree.opened (an existing worktree +// reopened into a workspace); both hand us a fresh workspace that wants tabs, so +// they share one handler. It finds the layout matching the 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 runOnWorktreeEvent(_ []string) { ev, err := parseWorktreeEvent(os.Getenv("HERDR_PLUGIN_EVENT_JSON"), os.Getenv) if err != nil { errExit("worktree event:", err) @@ -242,8 +246,8 @@ func runOnWorktreeCreated(_ []string) { } // 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. + // build on. They should always be present for a worktree 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)) } @@ -253,6 +257,18 @@ func runOnWorktreeCreated(_ []string) { errExit(err) } + // Idempotency guard. We subscribe to both worktree.created and worktree.opened, + // and herdr may also reopen a worktree workspace across sessions — so the + // handler can fire for a workspace we already laid out. A freshly created or + // opened worktree workspace has exactly one (root) pane, so more than one pane + // means the layout is already in place and we skip rather than stack a second + // copy of the tabs on top. A pane.list error returns 0, which fails open + // (proceeds) rather than wrongly skipping. + if n, err := client.workspacePaneCount(ev.WorkspaceID); err == nil && n > 1 { + fmt.Printf("herdr-plus: worktree workspace %q already has %d panes; skipping layout %q (already applied).\n", ev.WorkspaceID, n, layout.source) + return + } + if err := layoutTabs(client, ev.WorkspaceID, ev.RootTabID, ev.RootPaneID, layout.Tabs); err != nil { errExit("apply worktree layout:", err) } diff --git a/www/content/docs/troubleshooting.md b/www/content/docs/troubleshooting.md index 7a04486..be6ab33 100644 --- a/www/content/docs/troubleshooting.md +++ b/www/content/docs/troubleshooting.md @@ -76,9 +76,10 @@ 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: +Creating or opening a git worktree gave you a plain workspace instead of your +tabs. The [worktree auto-layout](../worktrees/) handler runs on herdr's +`worktree.created` and `worktree.opened` events and logs why it did or didn't act +— check the plugin log first: ```bash herdr plugin log list --plugin cloudmanic.herdr-plus @@ -86,6 +87,11 @@ herdr plugin log list --plugin cloudmanic.herdr-plus Common causes: +- **The worktree wasn't created through herdr.** herdr only fires these events for + worktrees it creates or opens itself (`herdr worktree create`/`open` or its + right-click worktree dialog). A worktree made with plain `git worktree add` — or + from lazygit or another tool — is invisible to herdr, so no event fires and no + layout applies. The plugin log will have **no** `on-worktree` entry at all. - **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. diff --git a/www/content/docs/worktrees.md b/www/content/docs/worktrees.md index 9474aab..975ecbf 100644 --- a/www/content/docs/worktrees.md +++ b/www/content/docs/worktrees.md @@ -1,24 +1,26 @@ --- 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." +description: "Automatically lay a project-style tab layout into a git worktree the moment herdr creates or opens it, driven by the plugin's worktree.created and worktree.opened events." 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. +creates or opens 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` and `worktree.opened` events and +fills the worktree's workspace for you. ## How it works -When you run `herdr worktree create` (or `herdr worktree open`), herdr: +When you run `herdr worktree create` (or `herdr worktree open`, or use herdr's +right-click worktree dialog), herdr: -1. Creates the git worktree. +1. Creates or opens the git worktree. 2. Makes a fresh herdr **workspace** for it, rooted at the worktree's directory. -3. Fires a `worktree.created` event. +3. Fires a `worktree.created` event (for a new worktree) or `worktree.opened` + event (for an existing one). -herdr-plus catches that event, looks at the worktree's **repo** (and branch), +herdr-plus catches either 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. @@ -100,9 +102,9 @@ 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: +The handler is wired to the plugin's `worktree.created` and `worktree.opened` +events, 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