Worktree
The cyber-mux/worktree subpath is the git-worktree adapter: the read helpers that enumerate
worktrees from git, the path resolution the whole capability keys on, and the gated removal. Worktree
facts (path, branch, merged, dirty) are always read from git on every backend — a multiplexer only
contributes the workspace binding.
import { resolvePrimaryRoot, listWorktreesFromGit, removeWorktreeSafely, isWorktreeRemovable, nodeExec, type WorktreeEntry,} from 'cyber-mux/worktree'Bound facade — worktreeApi
Section titled “Bound facade — worktreeApi”The ergonomic surface: worktreeApi(deps?) binds Exec + WorktreeFs once (defaulting to
nodeExec / nodeWorktreeFs), returning a WorktreeApi whose methods drop the runner — mirroring
how resolveMux binds the mux adapter.
import { worktreeApi } from 'cyber-mux/worktree'
const wt = worktreeApi() // or worktreeApi({ exec, fs }) to injectconst root = wt.primaryRoot()for (const entry of wt.list()) { /* ... */ } // list() defaults its root to primaryRoot()wt.removeSafely(somePath, { primaryRoot: root })| method | binds + wraps |
|---|---|
wt.primaryRoot() |
resolvePrimaryRoot |
wt.list(root?, signals?) |
listWorktreesFromGit — root defaults to primaryRoot(); signals tunes the merged probe |
wt.add(opts) |
gitWorktreeAdapter.add |
wt.provision(opts) |
provisionWorktree — reuse a free worktree or create one; available defaults to isWorktreeRemovable |
wt.prune(opts?) |
pruneWorktrees — remove every disposable worktree; dryRun reports without removing |
wt.removeSafely(path, opts) |
removeWorktreeSafely — its fs supplied |
wt.normalizePath(path) |
normalizeWorktreePath |
deps is WorktreeDeps { exec?, fs? }. The functions below are the raw seam worktreeApi binds
— call them directly to thread your own runner per call. Pure helpers (isWorktreeRemovable,
resolveWorktreePath, assertDistinctFromPrimary) have no seam and stay free functions.
Reading worktrees
Section titled “Reading worktrees”resolvePrimaryRoot(exec)
Section titled “resolvePrimaryRoot(exec)”The primary checkout’s root, whether the caller’s cwd is the primary checkout or a linked worktree
(via --git-common-dir). This is the canonical anchor every other helper resolves against, so a
worktree branched from an old commit sees the same answer as the primary.
listWorktreesFromGit(exec, ...) → WorktreeEntry[]
Section titled “listWorktreesFromGit(exec, ...) → WorktreeEntry[]”Every worktree git reports. Each WorktreeEntry carries what listing must represent that creation
cannot — a detached HEAD, the primary checkout itself, a stale entry:
root— absolute, normalized checkout path.branch— absent for a detached HEAD or bare entry.linked—falsefor the primary checkout,truefor a linked worktree.prunable— git considers the checkout gone from disk.merged— the branch’s work has landed on the default branch, established by four independent positive signals: ancestry, an upstream branch that is gone, a squash patch match, and an opt-in forge probe. Absent when undeterminable. A squash or rebase rewrites the tip, so ancestry alone would miss it; the patch heuristic catches a clean squash, and one that was conflict-resolved or hand-edited still readsfalse— the error is deliberately one-directional, costing a manual check rather than lost work.mergedSignal— which of the four proved it (ancestor|upstream-gone|squash-patch|forge). Absent whenmergedis nottrue.dirty— the checkout has uncommitted changes. A merged-but-dirty worktree is not disposable.workspace— the multiplexer workspace it is open in, joined in by the caller from a backend binding; absent otherwise.
isWorktreeRemovable(entry)
Section titled “isWorktreeRemovable(entry)”Whether an entry is safe to dispose — the merged-and-clean predicate over the fields above.
Path helpers
Section titled “Path helpers”normalizeWorktreePath(path, fs?)— symlink-resolved, native-cased path; the normalization every matched path goes through.resolveWorktreePath(primaryRoot, name)— where a worktree namednameshould be checked out.assertDistinctFromPrimary(worktreeRoot, primaryRoot)— throws if a worktree path is the primary checkout, the guard behind any removal.
The fs parameter is a WorktreeFs seam (exists / realpath); nodeWorktreeFs is the default.
Removing a worktree
Section titled “Removing a worktree”removeWorktreeSafely(exec, ...)
Section titled “removeWorktreeSafely(exec, ...)”Removal is always cyber-mux’s own gates plus git worktree remove — never delegated to a backend,
so a destructive operation’s safety never depends on whether a workspace happened to be open. A
refused removal throws a WorktreeGitError, whose message is this CLI’s own prose (safe to surface
verbatim).
Binding a worktree to a workspace
Section titled “Binding a worktree to a workspace”The worktree-workspace capability — present on herdr and undefined on tmux, rmux, WezTerm, Zellij, cmux, and otty — is
the one part a multiplexer owns: binding a worktree to a workspace as a first-class record the UI
groups a repo’s checkouts by. Empirically, plain git worktree add + workspace create yields no
binding; only routing through herdr’s own worktree create/open produces it.
Reach it bound, off a resolved MuxSession, as mux.worktree (a BoundWorktreeWorkspaceCapability
— methods take deps? instead of a leading exec) — this is the preferred, ergonomic route:
const mux = resolveMux(process.env)if (mux.worktree) { mux.worktree.createInWorkspace({ /* ... */ })}Or reach it raw, off the exec-first MuxAdapter, as
adapter.worktree — the same capability, exec-first:
createInWorkspace(opts, deps?)/createInWorkspace(exec, opts)— create a worktree and open it in a bound workspace.openInWorkspace(opts, deps?)/openInWorkspace(exec, opts)— open an existing worktree in a bound workspace (the remedy that groups a worktree plain git created earlier).bindings({ primaryRoot }, deps?)/bindings(exec, { primaryRoot })→Map<path, workspace>— which workspace each worktree is open in; the one fact git cannot answer.releaseWorkspace(workspace, opts?, deps?)/releaseWorkspace(exec, workspace, opts?)— close the workspace, releasing the binding without touching the checkout on disk. When the workspace is a primary one that still has worktree workspaces open on it, the close reaches the whole group:opts.groupdefaults totrue, which herdr 0.9.0 spellsworkspace close --group(it cascaded implicitly through 0.8.2). Pass{ group: false }to release only the workspace named and leave its worktree workspaces up.
Every member here opens a workspace; none is a route for a bare worktree add — that is always plain
git. On tmux (mux.worktree/adapter.worktree === undefined) callers fall back to plain git plus a
placement-appropriate mux.open.