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
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,53 @@ the launch context: `{{.WorkDir}}` (where you launched from), `{{.SessionTitle}}
as `HERDR_PLUS_*` environment variables. If a command doesn't reference
`{{.Value}}`, the value is appended as a final shell-quoted argument.

## Worktree auto-layout

herdr-plus can lay a project-style tab layout into a git **worktree** the moment
herdr creates it. When you run `herdr worktree create` (or open one), herdr makes a
fresh workspace for the worktree and fires a `worktree.created` event; herdr-plus
catches it, finds a layout matching the worktree's repo, and opens that layout's
tabs and panes in the new workspace — every command running — with no keypress.
This is the plugin system's `[[events]]` hook (declared in
[`herdr-plugin.toml`](herdr-plugin.toml)) put to work.

Layouts live in `~/.config/herdr-plus/worktrees/`, one TOML file per layout (the
file name doesn't matter). A layout is a `repo` matcher plus the same `[[tabs]]`
format projects use:

```toml
repo = "options-cafe" # matches the worktree's repo name (case-insensitive)

[[tabs]]
name = "claude"
command = "claude --dangerously-skip-permissions --chrome"

[[tabs]]
name = "lazygit"
command = "lazygit"

[[tabs]]
name = "terminal" # no command — just an empty shell
```

- **`repo`** (required) matches the new worktree's repository name — the repo's
basename, e.g. `options-cafe` — case-insensitively.
- **`branch`** (optional) narrows a layout to worktrees created on exactly that
branch. When more than one layout matches, a branch-specific one wins over a
repo-only one.
- **`[[tabs]]`** is identical to a project's tabs, including multi-pane
`[[tabs.panes]]` splits (see [Split panes within a tab](#split-panes-within-a-tab)).

### Turning a layout on and off

The switch is simply **whether the file exists**. A layout in `worktrees/` is on;
to turn one off, delete the file (or move it out of the directory). With no files
in `worktrees/` at all, the feature is inert — every worktree fires the event, and
herdr-plus does nothing when nothing matches.

The handler's output shows up in `herdr plugin log list --plugin
cloudmanic.herdr-plus`, so you can confirm whether a layout fired.

## Binding a key

Binding keys to the actions is an optional, one-time edit to **your** herdr
Expand Down
10 changes: 10 additions & 0 deletions herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand Down
19 changes: 14 additions & 5 deletions project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
Expand Down
261 changes: 261 additions & 0 deletions worktree.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
//
// Date: 2026-06-16
// Author: Spicer Matthews (spicer@cloudmanic.com)
// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved.
//

package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/BurntSushi/toml"
)

// WorktreeLayout is a declarative tab layout applied automatically when a herdr
// git worktree is created. Where a Project is opened on demand and creates its
// own workspace, a worktree layout reacts to herdr's `worktree.created` event:
// herdr has already made the worktree's workspace, so the layout just fills it
// with tabs. It reuses the same ProjectTab model, so worktree layouts get the
// full single-command / split-pane vocabulary that projects have.
//
// Layouts live in ~/.config/herdr-plus/worktrees/, one TOML file per layout (the
// file name does not matter). With no files there, the feature is simply inert.
type WorktreeLayout struct {
// Repo selects which worktrees this layout applies to, matched
// case-insensitively against the new worktree's repo name (the repository's
// basename, e.g. "options-cafe"). Required.
Repo string `toml:"repo"`

// Branch, when set, narrows the layout to worktrees created on exactly this
// branch (case-insensitive). Empty applies to every branch of the repo, and a
// branch-specific layout is preferred over a repo-only one when both match.
Branch string `toml:"branch"`

// Tabs is the ordered list of tabs to open in the worktree's workspace,
// identical in shape to a Project's tabs (a single `command`, or multiple
// [[tabs.panes]] splits).
Tabs []ProjectTab `toml:"tabs"`

// source is the file the layout was loaded from, used only for error and log
// messages. It is not part of the on-disk format.
source string
}

// validate checks that a layout is internally consistent before we ever act on a
// worktree event, turning config mistakes into clear errors at load time.
func (l WorktreeLayout) validate() error {
if strings.TrimSpace(l.Repo) == "" {
return fmt.Errorf("worktree layout %s: repo is required", l.source)
}
if len(l.Tabs) == 0 {
return fmt.Errorf("worktree layout %q (%s): needs at least one [[tabs]] entry", l.Repo, l.source)
}
return validateTabs(l.Repo, l.source, l.Tabs)
}

// matches reports whether this layout applies to the given worktree event. The
// repo must match (against either the repo name or the basename of the repo
// root, case-insensitively); a layout with a Branch additionally requires the
// worktree's branch to match.
func (l WorktreeLayout) matches(ev worktreeEvent) bool {
repo := strings.TrimSpace(l.Repo)
if !strings.EqualFold(repo, ev.RepoName) && !strings.EqualFold(repo, filepath.Base(ev.RepoRoot)) {
return false
}
if l.Branch != "" && !strings.EqualFold(strings.TrimSpace(l.Branch), ev.Branch) {
return false
}
return true
}

// worktreesConfigDir returns the directory that holds worktree layout files,
// ~/.config/herdr-plus/worktrees. Like projects, it hangs directly off the
// herdr-plus config root rather than under a mode slug.
func worktreesConfigDir() (string, error) {
base, err := configBaseDir()
if err != nil {
return "", err
}
return filepath.Join(base, "worktrees"), nil
}

// loadWorktreeLayouts reads, parses, and validates every *.toml layout in the
// worktrees directory, returning them sorted by file name. A missing directory
// yields no layouts and no error, so the feature is opt-in: with nothing there,
// the worktree handler is a no-op. A malformed or invalid file fails the whole
// load with a message naming the offending files, so config mistakes surface in
// the plugin log instead of a layout silently going missing.
func loadWorktreeLayouts() ([]WorktreeLayout, error) {
dir, err := worktreesConfigDir()
if err != nil {
return nil, err
}

entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}

var layouts []WorktreeLayout
var problems []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") {
continue
}
path := filepath.Join(dir, e.Name())

var l WorktreeLayout
if _, err := toml.DecodeFile(path, &l); err != nil {
problems = append(problems, fmt.Sprintf(" %s: %v", e.Name(), err))
continue
}
l.source = e.Name()
if err := l.validate(); err != nil {
problems = append(problems, " "+err.Error())
continue
}
layouts = append(layouts, l)
}

if len(problems) > 0 {
return nil, fmt.Errorf("invalid worktree layout files in %s:\n%s", dir, strings.Join(problems, "\n"))
}

sort.Slice(layouts, func(i, j int) bool { return layouts[i].source < layouts[j].source })
return layouts, nil
}

// matchWorktreeLayout returns the best matching layout for an event, if any.
// Among all matching layouts a branch-specific one wins over a repo-only one (it
// is more specific); ties break by file name, which loadWorktreeLayouts already
// sorts by.
func matchWorktreeLayout(layouts []WorktreeLayout, ev worktreeEvent) (WorktreeLayout, bool) {
var best WorktreeLayout
found := false
for _, l := range layouts {
if !l.matches(ev) {
continue
}
if !found {
best, found = l, true
continue
}
// A branch-specific match beats the repo-only match we already have.
if best.Branch == "" && l.Branch != "" {
best = l
}
}
return best, found
}

// worktreeEvent is the subset of the `worktree.created` payload herdr-plus acts
// on: the repo and branch (for matching a layout) plus the ids of the workspace,
// root tab, and root pane herdr already created for the worktree (to lay the
// layout into).
type worktreeEvent struct {
WorkspaceID string
RootTabID string
RootPaneID string
RepoName string
RepoRoot string
Branch string
CheckoutPath string
}

// worktreeCreatedPayload mirrors the JSON herdr puts in HERDR_PLUGIN_EVENT_JSON
// for a `worktree.created` event. Only the fields we use are declared.
type worktreeCreatedPayload struct {
Data struct {
Workspace struct {
WorkspaceID string `json:"workspace_id"`
ActiveTabID string `json:"active_tab_id"`
Worktree struct {
RepoName string `json:"repo_name"`
RepoRoot string `json:"repo_root"`
CheckoutPath string `json:"checkout_path"`
} `json:"worktree"`
} `json:"workspace"`
Worktree struct {
Path string `json:"path"`
Branch string `json:"branch"`
} `json:"worktree"`
} `json:"data"`
}

// parseWorktreeEvent builds a worktreeEvent from the event JSON and the plugin
// environment. herdr provides the workspace/tab/pane ids both as HERDR_* env vars
// and (for workspace and tab) inside the payload; we prefer the env vars and fall
// back to the payload. The root pane id comes only from HERDR_PANE_ID. getenv is
// injected so the parsing is unit-testable without touching the real environment.
func parseWorktreeEvent(eventJSON string, getenv func(string) string) (worktreeEvent, error) {
var p worktreeCreatedPayload
if strings.TrimSpace(eventJSON) != "" {
if err := json.Unmarshal([]byte(eventJSON), &p); err != nil {
return worktreeEvent{}, fmt.Errorf("parse HERDR_PLUGIN_EVENT_JSON: %w", err)
}
}
return worktreeEvent{
WorkspaceID: firstNonEmpty(getenv("HERDR_WORKSPACE_ID"), p.Data.Workspace.WorkspaceID),
RootTabID: firstNonEmpty(getenv("HERDR_TAB_ID"), p.Data.Workspace.ActiveTabID),
RootPaneID: getenv("HERDR_PANE_ID"),
RepoName: p.Data.Workspace.Worktree.RepoName,
RepoRoot: p.Data.Workspace.Worktree.RepoRoot,
Branch: p.Data.Worktree.Branch,
CheckoutPath: firstNonEmpty(p.Data.Worktree.Path, p.Data.Workspace.Worktree.CheckoutPath),
}, nil
}

// runOnWorktreeCreated is the `worktree.created` event handler herdr invokes (via
// the [[events]] entry in herdr-plugin.toml). It finds the enabled layout that
// matches the new worktree and lays its tabs into the workspace herdr already
// created. With no matching layout it does nothing — every worktree fires this, so
// a quiet no-op is the common, correct case. Output goes to stdout/stderr, which
// herdr captures in the plugin log (`herdr plugin log list --plugin
// cloudmanic.herdr-plus`).
func runOnWorktreeCreated(_ []string) {
ev, err := parseWorktreeEvent(os.Getenv("HERDR_PLUGIN_EVENT_JSON"), os.Getenv)
if err != nil {
errExit("worktree event:", err)
}

layouts, err := loadWorktreeLayouts()
if err != nil {
errExit(err)
}

layout, ok := matchWorktreeLayout(layouts, ev)
if !ok {
// No layout for this repo/branch — the expected case for most worktrees.
// The feature is opt-in: a layout exists only if you put a file in
// worktrees/, so a quiet no-op here is the common, correct path.
fmt.Printf("herdr-plus: no worktree layout matches repo %q (branch %q); nothing to do.\n", ev.RepoName, ev.Branch)
return
}

// We need the workspace herdr made for the worktree and its root tab/pane to
// build on. They should always be present for a worktree.created event; bail
// loudly if not so the failure is visible in the plugin log.
if ev.WorkspaceID == "" || ev.RootTabID == "" || ev.RootPaneID == "" {
errExit(fmt.Sprintf("worktree event missing ids (workspace=%q tab=%q pane=%q)", ev.WorkspaceID, ev.RootTabID, ev.RootPaneID))
}

client, err := newHerdrClient()
if err != nil {
errExit(err)
}

if err := layoutTabs(client, ev.WorkspaceID, ev.RootTabID, ev.RootPaneID, layout.Tabs); err != nil {
errExit("apply worktree layout:", err)
}

fmt.Printf("herdr-plus: applied worktree layout %q to repo %q (branch %q): %d tab(s).\n", layout.source, ev.RepoName, ev.Branch, len(layout.Tabs))
}
Loading
Loading