Files
max-sixty__worktrunk/CLAUDE.md
T
Maximilian Roos bb43011c8d fix(picker): cancel background commands when the picker exits (#3560)
## What

`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.

Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.

Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.

## Why

Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.

Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:

| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |

Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).

## Why a latch, not just a sweep

A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.

Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.

This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.

## Notes

- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
  the picker stays live, so pressing Enter straight after can leave a
  `git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
  and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
  removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
  each call site.

- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
  an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
  back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
  still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
  test is deterministic (a file marker for readiness, not a sleep).

An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.

Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.

Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
  which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
  are pre-existing: the `spawn_removal` refactor re-indents the dispatch
  closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
  judged worth more than the metric.

- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
  by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00

25 KiB
Raw Permalink Blame History

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-tend skill).

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 calls warning_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. --force on 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 --merge over --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 merge verifies clean before rebasing, but files could appear before cleanup — don't force-remove during cleanup.

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 plain wt list (any format) when [list] columns names ci, which forces the column (and its fetch) on without --full
  • wt 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 background gh pr view <n> --json comments (glab api …/notes on GitLab) fills that row's comments preview tab — the same fetch a --prs row makes, spawned once per row from progressive_handler (see picker::prs::spawn_comments_fetch). The comments tab is the only PR data fetched lazily here; pr rides the CI call and log is the local git log
  • generating a branch summary with a commit.generation command
  • generating a commit message with a commit.generation command
  • wt switch pr:<n>, wt switch mr:<n> — host API to resolve the PR/MR, then git fetch of its branch
  • wt switch --prs — one gh pr list / glab mr list to populate the interactive picker (streamed in after the frame paints), then a per-row background gh pr view <n> --json comments (glab api …/notes on GitLab) to fill each row's comments preview tab, plus a gh pr view <n> --json commits / glab api …/commits for the log tab only when the head commit isn't already local — a --prs row whose headRefOid/sha resolves in the object store renders the log tab from a local git log with no network (off the pool, once per row when the rows land — see picker::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) as WorktrunkError::ChildProcessExited { signal: Some(sig), .. }, capture mode (Cmd::run) as CommandError { signal: Some(sig), .. }. These fields are the structured channel — never sniff code >= 128 or parse error messages.
  • Detect via err.interrupt_signal() (the worktrunk::git::ErrorExt trait). When it returns Some(signal), propagate as WorktrunkError::Interrupted { signal, hint } and break the loop. Interrupted exits 128 + signal (130 SIGINT, 143 SIGTERM) and renders once, at exit, per shell convention: silent for SIGINT (the terminal echoed ^C), Terminated for SIGTERM — the line the shell would print if wt weren't trapping the signal. hint carries 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_signal in shell_exec, the concurrent runner's originating-signal override).
  • The check happens before any FailureStrategy branch — Warn must NOT swallow signal-derived errors.
  • handle_command_error in src/commands/command_executor.rs enforces this for hook and alias pipelines (foreground and concurrent groups); for_each.rs enforces 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) or concat!(). 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).