`fs::write` truncates in place, so a crash, a full disk, or a lost power cable between the truncate and the write leaves the file empty or half-written. #3585 fixed that for the rc file `wt config shell uninstall` rewrites and left the rest of the class open, pending a decision on whether every site should share one writer. It should, so this is that writer plus the sites it covers. ## Sites Four were truncating a file worktrunk can't put back: - `config/user/persistence.rs` — `UserConfig::save` reads the user's whole `config.toml`, merges, and writes it back. The highest-traffic of these, and the one not listed in #3585's follow-ups. Its lock is on `config.toml.lock`, a separate file, so the rename doesn't disturb it. - `commands/config/update.rs` — the migrated config `wt config update` writes. - `commands/config/plugins.rs` — the Claude `settings.json` the statusline setup merges `statusLine` into. Everything else in that file is the user's. - `commands/configure_shell.rs` — the rc file, shell wrapper, and fish completion `wt config shell install` creates. A half-written wrapper is one the user's shell sources at startup. Two already did this correctly, each in its own way: `write_approvals_file` and the uninstall rewrite from #3585. Both now call the shared function, so the rule is one place rather than a convention every site reimplements. `cache::write_json` and the `-vv` diagnostic report keep the plain write. A torn write there costs a recompute, not the user's data, and an fsync per cache entry isn't worth paying for. ## Why not a crate `atomic-write-file` is the serious candidate: same-directory temp, Windows and WASI support, and the only one that carries ownership across, which this helper can't. But it replaces a symlink rather than writing through it ("if the path of an `AtomicWriteFile` is a symlink to another file, the symlink is replaced"), so the canonicalize step stays either way, and it costs `nix` plus `rand`. `atomicwrites` does neither mode nor symlinks and creates a temp subdirectory per write. `tempfile` is already a runtime dependency and does the hard part, which left about ten lines. The comparison is in the function's docstring so it isn't re-litigated. ## Behavior changes A newly *created* file lands at `0600` rather than the `0644` a truncating write leaves under a default umask. An existing target's mode is copied across, but a fresh temp file is `0600` and there is no non-racy way to read the umask from std. Everything written here is read by its own owner (`.bashrc`, `wt.fish`, `config.toml`, `settings.json`), it fails safe, and `approvals.toml` has shipped this way since it started using a temp file. Creating the temp file needs a writable parent directory, which a truncating write did not, so a read-only directory holding a writable file now fails with the file left as it was. ## Testing The three uninstall tests from #3585 now exercise the shared function: mode preserved with no temp file left behind, the rewrite following a symlink into a different directory, and the rc file intact when the write fails. Two new unit tests cover what those don't — the create-then-replace round trip leaving no stray, and the `0600` a created file gets. Also driven through the real CLI rather than only tests. `wt config update` against a `0644` `config.toml` symlinked into a dotfiles directory rewrote the real file and left the link, the mode, and the directory as they were. `wt config shell install` into a fresh `$HOME` produced the `0600` rc file, wrapper, and completion above. > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 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.
Project Status
Maturing mode: a growing user base, so balance clean design with compatibility.
- 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 in .claude/skills/:
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, not filesystem path.
- 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/content/ 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/content/, 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 files, never truncate them —
fs::writetruncates before it writes, so a crash mid-write leaves the file empty. Every write to a file worktrunk can't put back (rc files, 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. Regenerable content (the cache, the-vvdiagnostic report) keeps the plain write.
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.
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. 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(any format) when[list] columnsnamesci, which forces the column (and its fetch) on without--fullwt 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. 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).