Make new user-facing concepts justify their code and documentation surface, and prefer consolidation before adding another special case.
29 KiB
Worktrunk Development Guidelines
Quick Start
cargo run -- hook pre-merge --yes # all tests + lints (runs automatically in wt merge)
Claude Code web: run task setup-web first. Test commands, isolation, and coverage investigation: tests/CLAUDE.md.
Codex Cloud: use the setup in .codex/cloud.sh.
Project Status
Maturing mode: a growing user base, so balance clean design with compatibility.
- New features are welcome, but a command, option, user-facing concept, or configuration key is a product decision. Its user value must justify the code and documentation surface it adds.
- Consolidate or remove existing prose and machinery before adding another explanation, mode, or special case.
- External-interface breaks need justification (a real improvement, not cleanup); prefer deprecation warnings over silent breaks.
- Protected interfaces: config file format (
wt.toml, user config) and CLI flags/arguments. Everything else (internal APIs, output formatting, log locations) is flexible. - No Rust library compatibility concerns (CLI tool only).
- MSRV: latest stable − 1, bumped during weekly tend maintenance (
running-tendskill).
Terminology
Use consistently in docs, help text, and code comments:
- main worktree — the original git directory (from clone/init); bare repos have none
- linked worktree — created via
git worktree add(git's term) - primary worktree — the "home" worktree: main worktree for normal repos, default-branch worktree for bare repos
- default branch — the branch (main, master, …), not "main branch"
- target — destination for merge/rebase/push ("merge target"). Never use "target" for worktrees; say "worktree"
Skills
Load relevant skills before starting; reload when scope changes mid-session. Project-local skills live in .claude/skills/, with .agents/skills linking Codex to the same files:
writing-user-outputs— before editing code that callswarning_message,hint_message,error_message,info_message,eprintln,println, or otherwise produces user-visible strings (CLI help, progress UI, snapshots).running-tend— operating in CI or writing tend workflows.release— cutting a release.
Worktree Model
- Worktrees are addressed by branch name, with a worktree's own path as an alias — resolved branch-first by
Repository::resolve_worktree, the one canonicalizer every worktree-naming argument routes through. A path is not a second addressing scheme: it names what a branch cannot (a detached worktree, one of two checkouts of a branch). So document arguments as taking a branch, state the path alias once rather than per argument, and give a new argument the canonicalizer rather than its own rule. - Each worktree maps to exactly one branch.
- Never retarget an existing worktree to a different branch; create/switch/remove instead. (Sole exception:
wt step promote, experimental, exchanges branches between two worktrees.)
Documentation
Behavior changes require doc updates. src/cli/mod.rs (after_long_help plus clap attributes) is the PRIMARY SOURCE for command pages; their rendered mirrors in docs/src/content/docs/ and skills/worktrunk/reference/ are generated, as is all of plugins/worktrunk/skills/ — but both directories also hold hand-edited primaries (non-command docs in docs/src/content/docs/, skill-only pages like shell-integration.md in the reference dir), so check which file is primary in the sync taxonomy before editing. Ask: "does --help still describe what the code does?" cargo test --test integration test_docs_are_in_sync checks doc sync; editing help text (after_long_help, about, arg docs) also changes the rendered --help snapshots, which that test leaves untouched — cargo insta test --accept --test integration -- test_help regenerates them (the pre-merge hook runs both). Sync taxonomy, help-text authoring (three render contexts, link text, config-TOML blocks): docs/CLAUDE.md.
Plugin Layout
Per-tool layout and path resolution (Claude/Codex/Gemini), the convention-only Claude manifest, the Codex inline-hooks rationale, the generated plugin-skills mirror, the accepted wt-switch-create tradeoff, and test_plugin_layout_is_consolidated: plugins/worktrunk/CLAUDE.md.
Data Safety
Never risk data loss without explicit user consent. A failed command that preserves data beats a "successful" one that silently destroys work.
- Prefer failure over silent loss — if an operation might destroy untracked files, uncommitted changes, or user data, fail with an error.
- Explicit consent for destructive ops — force-removing data (e.g.
--forceon remove) requires the user to explicitly request it. - No implicit destructive side effects — never silently delete/overwrite as a side effect of an unrelated operation; make cleanup a separate explicit action the user chooses.
- Favor the failing variant on races —
git reset --keep(fails if tracked files were modified) over--hard;git checkout --mergeover--force. If no safer variant exists, document the risk inline. - Time-of-check vs time-of-use — be conservative when there's a gap between the safety check and the operation.
wt mergeverifies clean before rebasing, but files could appear before cleanup — don't force-remove during cleanup. - Replace full files, never truncate them —
fs::writetruncates before it writes, so a crash mid-write leaves the file empty. Every full-file rewrite worktrunk can't put back (shell wrappers,config.toml,approvals.toml, another tool'ssettings.json) goes throughutils::write_atomically, which renames a sibling temp file over the target; the spec on that function covers symlinks, mode, and what a rename costs. When a user-owned file was observed missing,utils::write_new_atomicallyrefuses to overwrite one that appears before persistence. Adding to an existing Bash, Zsh, or PowerShell rc file instead opens it in append mode, so a stale snapshot cannot replace concurrent edits; never truncate the file to recover from an append error. Don't grow the install path into a rebuild. Removal still rewrites those rc files whole (uninstall_previewed_lines), which is why thewrite_atomicallyspec still names them. Regenerable content (the cache, the-vvdiagnostic report) keeps the plain write. - Shell-config concurrency has a deliberate boundary — the append lock coordinates Worktrunk installers, and no-clobber creation turns the missing-file race into a fail-and-rerun outcome. An editor save racing rc-file uninstall can still be overwritten; accept this final check-to-rename window because sidecar locks, backups, retries, and extra pre-rename checks do not close it. Revisit after an observed incident or a simpler write design. Worktrunk-owned wrappers and completions use last-writer-wins semantics, as does the merge into another tool's
settings.json.
These stop where git's own protections stop, and matching git is deliberate in each case. The named spec says why:
wt mergeandwt step pushoverwrite an ignored file in the destination worktree whose path the incoming commits track, exactly as agit mergerun there would (src/commands/worktree/push.rs).- Removal's final dirty-worktree gate is answered by the fsmonitor daemon under
core.fsmonitor, exactly asgit worktree remove's own gate is (src/git/remove.rs).
Full inventory: FAQ What files does Worktrunk create? and What can Worktrunk delete?. Review new code that changes this surface against those sections.
Command Execution Principles
All Commands Through shell_exec::Cmd
Every external command goes through shell_exec::Cmd for consistent debug logging ($ git status [worktree-name]) and [wt-trace] timing. Never call cmd.output() directly. For git, prefer Repository::run_command() (wraps Cmd with worktree context). Cmd has four execution modes — run (capture), stream (inherit stdio), delayed_stream (buffer then stream to stderr, for slow ops like git worktree add), and pipe_into (two-stage pipe). Pipe stdin via .stdin_bytes(...).
Cmd::new("git").args(["status", "--porcelain"]).current_dir(&wt).context("worktree-name").run()?;
Cmd::new("gh").args(["pr", "list"]).run()?; // no context for standalone tools
The [wt-trace] command record has one emitter: CommandTrace in src/trace/emit.rs. The grammar lives there too (don't hand-write log::debug!("[wt-trace] …")). CommandTrace::{complete,fail} are the only callers of the private command_completed/command_errored writers, so a subprocess is either traced through the guard or produces no command record. Most spawns get this for free via Cmd. A few spawn sites have I/O shapes Cmd can't model and construct a CommandTrace directly: the concurrent-command runner (output/concurrent.rs), pipeline steps (commands/run_pipeline.rs), wt step tether, and the fsmonitor daemon launch. Any new spawn site that runs an in-process command must construct a CommandTrace (start it just before spawn; complete(success) after wait, fail(err) on spawn/wait error) — otherwise the command shows up as an unattributed gap in wt-perf timeline. The guard is #[must_use] and trips a debug-build assertion if dropped unresolved, so a forgotten complete/fail fails tests rather than silently going untraced. Detached background children (commands/process.rs) and interactive helpers (pagers, shell probes) are intentionally untraced — they outlive the invocation or aren't part of its timeline.
Git-Discovery Env Vars Follow Who Chose the Cwd
Git resolves GIT_DIR/GIT_WORK_TREE (and the rest of INHERITED_GIT_PATH_VARS) before walking up from the cwd, so an inherited value silently overrides a child's working directory. Any spawn site that relocates a user command into a wt-chosen worktree — hooks, wt step for-each, the --execute no-integration fallback — must scrub these vars (Cmd::scrub_git_discovery_env or scrub_git_discovery_env_vars); children running in the user's own context (aliases, commit.generation) and wt's internal git plumbing keep the inherited context (absolutized). Full site classification and rationale: scrub_git_discovery_env_vars in src/shell_exec.rs.
Real-time Output Streaming
Stream command output line-by-line rather than buffering. Responsiveness is a priority.
Structured Output Over Error-Message Parsing
Prefer exit codes / --porcelain / --json over parsing human-readable messages, which break on locale, version, and rewording changes. git merge-base exit codes encode meaning (0 found, 1 no common ancestor, 128 invalid ref) — branch on status.code(), not message text.
| Tool | Fragile | Structured |
|---|---|---|
git diff |
--stat (localized) |
--numstat, --shortstat ((+)/(-) hardcoded) |
git status |
default | --porcelain=v2 |
git merge-base |
error messages | exit codes |
gh / glab |
default | --json |
When no structured alternative exists, document the fragility inline.
Immutable Ids Over List Positions
stash@{0} names a position in a list any process can reorder, so a handle captured before a mutation window and used after it can resolve to a different object — restoring the target worktree's autostash by position after git push silently restored a concurrent writer's entry and reported success. Capture the immutable id instead (git stash list --format=%H, git stash create, rev-parse) and act on that; where an operation accepts only a positional selector, re-derive it from something stable immediately beforehand. An index into a collection wt owns is a different thing — this is about namespaces other processes can mutate. The strongest form is not to enter the shared namespace at all: the autostash this rule came from was later deleted outright, replaced by a two-tree merge that leaves the target worktree's changes in place (advance_target in src/commands/worktree/push.rs).
Network Access
worktrunk is local-first: the network is touched only when the user asked for it, and only where reaching the wire directly serves that request. One detection helper is exempt: the first Repository::default_branch() per repo may fall through to git ls-remote; the result caches in worktrunk.default-branch and every later call is local. The query is bounded by REMOTE_DETECTION_TIMEOUT — nothing in git bounds it, and an unreachable host costs ~127 s per address on Linux — and a query that hits the bound falls back to local inference without caching it, so an outage can't make a guess permanent. No other detection helper may add a similar fallback.
Why: silent "lookup" paths that walk to the wire (alias dispatch, hook context build, recovery) stall commands the user wouldn't expect to do network work, worst on a fresh clone. The default_branch() bootstrap keeps a fresh clone usable while bounding the exception to one helper firing at most once per repo.
Network never blocks the first write. Fast output to the terminal is the priority (Real-time Output Streaming, above): every command paints from local data first, then network-derived detail streams in progressively behind it. A command that can't render its first frame until gh or git fetch returns is the failure mode, worst on a fresh clone or a slow link. Before adding an accessor that could reach the wire (gh, glab, git fetch, git ls-remote, HTTP), confirm it renders progressively and never gates the first paint. A synchronous hot path like a shell prompt is stricter: it must not reach the wire at all, even progressively. wt list statusline is not such a path despite running on every prompt, because Claude Code consumes its output asynchronously.
The picker is the most forgiving home for network work, because its lifetime is bounded by the user, not the job. It paints immediately, the user browses, and a slow forge call streams into the rows whenever it arrives; if the user picks first, the picker's exit cancels the unfinished request (shell_exec::cancel_background_commands), so its latency never costs anything. A run-to-completion command is less forgiving: wt list renders progressively but still cannot finish until every task returns, so a slow gh call extends the command the user is waiting on. Prefer the picker for live forge data, and fetch it there progressively.
What currently reaches the wire:
wt list --full,wt list statusline— CI status; also plainwt list(table) when[list] columnsnamesci, which forces the column (and its fetch) on without--full.--format jsonplans off--fullalone, so a display setting can't send a machine-readable call to a forgewt switch(interactive picker, no target) — per-row CI status, primed from the local cache then fetched live and streamed into the rows; once a row's CI fetch surfaces an open PR/MR, a per-row backgroundgh pr view <n> --json comments(glab api …/noteson GitLab) fills that row'scommentspreview tab — the same fetch a--prsrow makes, spawned once per row fromprogressive_handler(seepicker::prs::spawn_comments_fetch). Thecommentstab is the only PR data fetched lazily here;prrides the CI call andlogis the localgit log- generating a branch summary with a
commit.generationcommand - generating a commit message with a
commit.generationcommand wt switch pr:<n>,wt switch mr:<n>— host API to resolve the PR/MR, thengit fetchof its branchwt switch --prs— onegh pr list/glab mr listto populate the interactive picker (streamed in after the frame paints), then a per-row backgroundgh pr view <n> --json comments(glab api …/noteson GitLab) to fill each row'scommentspreview tab, plus agh pr view <n> --json commits/glab api …/commitsfor thelogtab only when the head commit isn't already local — a--prsrow whoseheadRefOid/sharesolves in the object store renders thelogtab from a localgit logwith no network (off the pool, once per row when the rows land — seepicker::prs::spawn_pr_previews)wt config show --full— version check against GitHub- the first
Repository::default_branch()per repo —git ls-remote(above)
Signal Handling: Ctrl-C Cancels the Current Command
When a child process exits from a signal (SIGINT, SIGTERM), every loop in the foreground execution path MUST abort rather than continue to the next iteration. This applies to worktree loops (wt step for-each), hook pipelines, alias steps, concurrent groups, and any future code running multiple child processes in sequence.
Why: wt installs a signal_hook SIGINT/SIGTERM handler so it can forward signals to child process groups before exiting cleanly. As a side effect wt itself does not die from the user's Ctrl-C — only the current child does. Without this policy a single Ctrl-C against wt merge would charge through the remaining hook steps, with FailureStrategy::Warn silently swallowing each interrupt.
- Signal-derived child exits surface structurally: stream mode (
Cmd::stream) asWorktrunkError::ChildProcessExited { signal: Some(sig), .. }, capture mode (Cmd::run) asCommandError { signal: Some(sig), .. }. These fields are the structured channel — never sniffcode >= 128or parse error messages. - Detect via
err.interrupt_signal()(theworktrunk::git::ErrorExttrait). When it returnsSome(signal), propagate asWorktrunkError::Interrupted { signal, hint }and break the loop.Interruptedexits128 + signal(130 SIGINT, 143 SIGTERM) and renders once, at exit, per shell convention: silent for SIGINT (the terminal echoed^C),Terminatedfor SIGTERM — the line the shell would print if wt weren't trapping the signal.hintcarries an optional recovery line for state the interrupt left behind (e.g. a mid-rebase worktree). - In capture mode only SIGINT/SIGTERM classify as interrupts. Capture children get no forwarding or escalation, and their captured output would be discarded by the silent exit — so a child killed by any other signal (a crash, an OOM kill) surfaces as a visible error instead. A capture child with a
Cmd::timeoutis the one the tty broadcast doesn't reach: it runs in its own process group so expiry can tear down its whole tree (run_with_timeout_impl), which is what makes the bound bound anything, so a Ctrl-C during one waits out the remaining timeout. Stream mode counts any signal: output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (seen_signalinshell_exec, the concurrent runner's originating-signal override). - The check happens before any
FailureStrategybranch — Warn must NOT swallow signal-derived errors. handle_command_errorinsrc/commands/command_executor.rsenforces this for hook and alias pipelines (foreground and concurrent groups);for_each.rsenforces it for the worktree loop. New code that loops over child processes calls.interrupt_signal()on per-iteration errors and breaks.
Project Commands Run Only After Approval
Policy: project-defined commands (pre-* / post-* hooks, [aliases], --execute bodies from project config) are arbitrary code shipped in a repo the user may have just cloned, so they run only after the approval system (Approvals plus approve_command_batch / approve_or_skip in src/commands/command_approval.rs) clears them. Never build a code path that runs project commands without that gate. A context that can't prompt (a TUI mid-render, a background recovery path) consults the approval state read-only and runs only the already-approved subset: commands::picker::do_removal builds the plan via HookPlan::approve_readonly (no prompt).
Why: the gate is the only thing between git clone && wt switch and a post-switch hook running curl … | sh. A "we already validated the operation, so run the hooks too" shortcut turns every command that touches project config into remote code execution.
Implementation: the operation-driven hooks (pre-merge, post-merge, pre-remove, post-remove, post-switch, pre-start, post-start) are gated before a state mutation and run after it, so a second config read could select an unapproved command. src/commands/hook_plan.rs closes this structurally: each gate (wt remove / wt merge / wt step prune / wt switch) selects the command set once into an immutable ApprovedHookPlan (HookPlan::approve); the executor consumes only that value via execute_planned_hook / register_planned and holds no ProjectConfig to re-derive from, so re-selection is a compile error, not a review check. An empty plan (--no-hooks, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (pre-commit, post-commit, pre-switch, wt hook <type>, aliases) still resolve config at invocation via execute_hook / HookAnnouncer::register. See src/commands/hook_plan.rs and the commands::hooks module spec.
Hook Output Logs
.git/wt/logs/ layout — per-branch and repo-wide log paths, plus the sanitize_for_filename filename rule: the HookLog spec in src/commands/process.rs. The top-level file-vs-directory split that wt config state walks: the "Log layout invariant" in src/commands/config/state.rs.
Coverage
codecov/patch gates the merge, not the design. Write the change the design calls for, then deal with the check. A predicted red patch is never a reason to shelve or water down an improvement, and neither the code nor the tests get contorted to move the number. The patch target is auto (the base commit's project coverage), so a handful of missed lines flips a small patch red, and a refactor that only relocates existing uncovered lines pulls their misses in without changing behavior.
NEVER merge a PR with failing codecov/patch without explicit user approval. It is marked "not required" in GitHub but still gates merge. On PR heads codecov posts check runs (codecov GitHub App), not commit statuses: poll with gh pr checks <number> or the check-runs API; the combined-status API (/commits/<sha>/status) never shows them, and the check run lands a few minutes after the code-coverage job finishes. On failure, close the gap where it's real — write tests, or delete code that's genuinely unused (a specialized error handler where falling through to the general one suffices; never a rarely-reached backstop that's load-bearing). Where it isn't real — misses that predate the change, or a path with no deterministic trigger — push the change and ask before merging, handing over the arithmetic: which lines, why they can't be covered, what the patch percentage comes to. Coverage runs include --features shell-integration-tests (CI code-coverage and local task coverage) — don't dismiss failures by claiming the feature is off. Investigation commands, moved-line false positives, and the "N functions mismatched" warning: tests/CLAUDE.md.
Benchmarks & Traces
cargo bench --bench list <filter> (Criterion takes a positional substring filter; there's no --skip). cargo run -p wt-perf -- timeline -- <args> traces one wt invocation. Real-repo benchmarks clone rust-lang/rust on first run. Benchmarks run as a standalone scheduled workflow (.github/workflows/benchmarks.yaml, daily cron plus workflow_dispatch), not on PRs, so they never gate a merge; only test (linux|macos|windows) block it. Filter map, expected numbers, and trace queries: benches/CLAUDE.md.
Code Quality
Use Existing Dependencies
Check Cargo.toml before hand-rolling a utility:
| Need | Use | Not |
|---|---|---|
| Path normalization | path_slash::PathExt::to_slash_lossy() |
.to_string_lossy().replace('\\', "/") |
| Shell escaping | shell_escape::unix::escape() |
manual quoting |
| ANSI colors | color_print::cformat!() |
raw escape codes |
| Template var detection | minijinja::undeclared_variables(false) |
regex/substring on {{ var }} |
Other
- Don't suppress warnings with
#[allow(dead_code)]— delete the code or add// TODO(topic): used by <upcoming work>. - System docstrings — complex systems (state machines, cached state, cross-module coordination, non-obvious invalidation) get a module-level spec docstring (purpose, key decisions, contracts, invariants); keep it current. Exemplar:
commands/list/collect/mod.rs. - No test code in library code — no
#[cfg(test)]convenience methods on library types; tests call the real API or define their own helpers. - Multiline strings — plain literals with real embedded newlines (
r#"…"#to avoid escaping"); never\continuation (silently strips following whitespace) orconcat!(). Place long constants at module level.
Error Handling
anyhow with context. bail! for business-logic errors (dirty worktree, missing branch, invalid state); .context() for wrapping I/O and external-command failures. Never .expect() / .unwrap() in a function returning Result — use ?, bail!, or return an error.
Config Deprecation
All config deprecation lives in one layer: pre-deserialization TOML migration in src/config/deprecation.rs. migrate_content() rewrites deprecated patterns into canonical form before serde parses; check_and_migrate() reuses it, and additionally detects patterns and emits per-process-deduped warnings (the user materializes migrations via wt config update). Never silently drop an old config key — that's a silent behavior change for users; migrate it.
Every deprecation is one row in the DEPRECATION_RULES table: a single idempotent function that rewrites the pattern AND returns the DeprecationKinds for what it changed — there is no separate detection function, so detection and migration share one predicate and cannot drift. Detection runs the same functions against a scratch copy of the document (progressively, so a rule sees earlier rules' rewrites); the invariant for warning rules is a warning fires exactly when wt config update would change the file, pinned by test_warning_fires_iff_update_changes — add new edge cases to its battery. The row variant decides when the rewrite applies: Structural rewrites on every load; UpdateOnly only via wt config update, for deprecated forms that still work at runtime; Silent rewrites on every load with no warning — its function signature has no channel for a kind, which is what scopes the invariant to Structural and UpdateOnly; PendingDefault adopts a default a future release switches — wt config update writes the upcoming value (currently [list] json-schema = 2), inert while the system config layer defines the key — update-pass only, scoped to the config kind that owns the key, and excluded from load warnings by is_pending_default: it satisfies the same iff at the surface that reads the setting, where the wt list JSON nag fires exactly when update would write. Table order is both the warning-emission order and the migration order. Each DeprecationKind carries its own display payload, so format_deprecation_warnings() is one match over the kinds. A config that can't be rewritten safely (a malformed value, an occupied destination key) is left untouched and unwarned — serde's type or unknown-field error is the messaging; an empty deprecated section is also left alone, with no message at all (it contributes no config). Adding a deprecation: (1) one idempotent migrate-and-report function; (2) a DeprecationKind variant plus its match arm in format_deprecation_warnings(); (3) a DEPRECATION_RULES row; (4) for a removed top-level section, add a DeprecatedSection to DEPRECATED_SECTION_KEYS (canonical key plus display form) so warn_unknown_fields defers to the deprecation messaging and suggests the correct config file. A silently-migrated rename (e.g. pre-create → pre-start) is a Silent row with no variant. Renaming a field within a section follows the same shape via a TOML-level rename function (see migrate_negated_bool); the struct never needs the old field since migration precedes serde.
Adding CLI Commands
Recipe, help-text placement, and flag-description conventions: src/commands/CLAUDE.md.
Accessor Function Naming
| Prefix | Returns | Side effects | Absent → | Example |
|---|---|---|---|---|
| (bare noun) | Option<T> / T |
none (may cache) | None/default | config(), switch_previous() |
set_* |
Result<()> |
writes state | errors | set_config() |
require_* |
Result<T> |
none | errors | require_branch() |
fetch_* |
Result<T> |
network I/O | errors | fetch_pr_info() |
load_* |
Result<T> |
file I/O | errors | load_project_config() |
No get_* — bare nouns follow Rust stdlib convention.
Repository Caching
Repository caches read-only values via Arc<RepoCache> (cloning shares it). What is and isn't cached, the list_worktrees() post-mutation invariant, the two storage patterns, and the in-memory-RepoCache-vs-persistent-sha_cache decision (cheap-and-hot → in-memory get-or-create; expensive → disk; both → in-memory front over disk back): the # Caching section in src/git/repository/mod.rs.
Releases
Use the release skill (version bump, changelog, crates.io publish, GitHub release).