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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]]`
Expand Down
22 changes: 15 additions & 7 deletions herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
24 changes: 24 additions & 0 deletions herdr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
Expand Down
48 changes: 32 additions & 16 deletions worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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))
}
Expand All @@ -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)
}
Expand Down
12 changes: 9 additions & 3 deletions www/content/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,22 @@ 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
```

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.
Expand Down
26 changes: 14 additions & 12 deletions www/content/docs/worktrees.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading