Skip to content
gwmgwmgwmv1.10.0

v0.7.0

Stable promotion of the full 0.7.0 release train. This consolidates every pre-release delta from rc.1, rc.2, and rc.3 into the stable notes.

Install with cargo install gwm --version 0.7.0 or from the release archives. MSRV is 1.82. No .gwm.toml migration is required.

  • ⚡ TUI sidebar commit graph pipes now carry git2::Oid values instead of heap-allocated hash strings (#108 / #142). This cuts the 300-row graph render benchmark by about 47% and keeps Pipe allocation-free.
  • ⚡ Recent Commits in the TUI sidebar now use a libgit2 revwalk cached by (worktree path, head OID, limit) instead of shelling out to git log on repeated sidebar rebuilds (#107 / #143). This cuts the 300-row sidebar benchmark by about 99%.

Historical delta: [0.7.0-rc.1] - 2026-05-22

Section titled “Historical delta: [0.7.0-rc.1] - 2026-05-22”

Delta against v0.6.0 stable. Merged PRs: #90, #91, #92, #109, #110, #111, #113, #115, #116, #118.

This RC bundles three configurability features (declarative labels / milestones / branch_types), three security hardening passes against attacker-controlled .gwm.toml (TOFU trust ledger, symlink-safe bootstrap, path-traversal rejection), a TUI regression fix on R: review, and end-to-end test coverage on the mutating subcommands.

  • Declarative GitHub labels (#81 / #90). New [[labels]] table in .gwm.toml declares the desired GitHub label set (name + optional description / color), plus a new subcommand:
    • gwm labels list - print the resolved set and the diff against the origin remote (+ create, ~ update, = match, - extra-on-remote).
    • gwm labels push - apply the diff via gh label create --force. --dry-run shows the plan without mutating the remote (it still reads remote labels via gh label list to compute the diff; only create / update / delete calls are skipped); --prune opt-in deletes labels on the remote that aren’t declared in config (destructive, off by default); --random-colors picks a random pastel for labels with no color field instead of the default deterministic-hash colour.
    • Colour resolution: when color is omitted, gwm derives a deterministic pastel from an FNV-1a hash of the name, so the same label gets the same colour across repos. Hex normalisation accepts #D73A4A and round-trips to d73a4a.
    • Without a [[labels]] block in .gwm.toml, both subcommands are no-ops (0 labels declared, nothing to push) and never shell out to gh - safe to run in repos that haven’t opted in.
    • Requires gh on $PATH (already a soft dependency of gwm status).
  • ✨ Configurable branch types (#80 / #91). New [[branch_types]] block in .gwm.toml overrides the built-in allowed branch types. Absent or empty block ⇒ historical defaults (feat, fix, hotfix, docs, test, refactor, chore, perf, ci, build); present ⇒ only the listed types are accepted by gwm create, the TUI create picker and BranchSpec::validate(). gwm types prints the resolved list with a (source: built-in defaults | .gwm.toml) footer and aligns columns on the longest name. Invalid-type errors now enumerate the repo-local allowed list verbatim instead of leaking the hardcoded set. Entries are validated at load time: name must be non-empty, match ^[a-z]+$, and be unique.
  • Declarative GitHub milestones (#82 / #92). New [[milestones]] table in .gwm.toml declares the desired GitHub milestone set (title + optional description / due_on / state), plus a new subcommand mirroring gwm labels:
    • gwm milestones list - print the resolved set and the diff against the origin remote (+ create, ~ update, = match, - extra-on-remote).
    • gwm milestones push - apply the diff via gh api repos/:owner/:repo/milestones (POST for new entries, PATCH for updates). No native gh milestone subcommand exists, so we shell out to the REST API directly. --dry-run shows the plan without mutating the remote; --prune opt-in deletes milestones on the remote that aren’t declared in config (destructive, off by default).
    • due_on accepts both YYYY-MM-DD (materialised as end-of-day UTC, common-sense “due Friday” semantic) and full RFC3339 (2026-07-15T17:00:00Z, canonicalised to UTC Z so non-UTC offsets don’t flip-flop the diff); state defaults to "open", opt in to "closed" for archived sprints.
    • Without a [[milestones]] block in .gwm.toml, both subcommands are no-ops (0 milestones declared, nothing to push) and never shell out to gh - same safe-by-default contract as labels.
    • Requires gh on $PATH.
  • 🔒 TOFU trust ledger on .gwm.toml + --allow-bootstrap / --deny-bootstrap flags (#95 / #113). Closes the arbitrary-RCE primitive against gwm create / gwm bootstrap on hostile clones: until this lands, running gwm against a freshly cloned (or PR-fork) repo was equivalent to curl … | sh against the repo author, with no trust boundary between “I cloned this remote” and “this remote can execute commands as me”.
    • Ledger at ~/.config/gwm/trust.toml (overridable via $GWM_TRUST_LEDGER) records (origin URL, sha256 of .gwm.toml, trusted_at, trusted_by) tuples. First run on a repo with a .gwm.toml prints a summary of the bootstrap surface (copies, guards, no-symlinks, command lines) and prompts Trust this .gwm.toml? [y/N/show]. Subsequent runs against the same (origin, hash) pass silently. Any byte change to .gwm.toml re-prompts (whitespace included - rm -rf /tmp/ and rm -rf /tmp / differ by one byte).
    • New subcommand gwm trust with three actions: list (audit the ledger), revoke <origin> (drop entries for an origin URL - verbatim match against the recorded form, SSH and HTTPS are distinct), show (print the active ledger path and contents).
    • Bypass flags: --allow-bootstrap (global, also GWM_ALLOW_BOOTSTRAP=1) skips the prompt without recording - for CI runners and scripted workflows where there is no human to answer. --deny-bootstrap refuses to run bootstrap even if the ledger says trusted - for forensic inspection of an unfamiliar repo.
    • Non-interactive safety: when stdin is not a tty and no --allow-bootstrap is in effect, gwm aborts with a clear message rather than hanging on a read that will never see input. The default-deny prevents a CI pipeline from silently running attacker code because the prompt got swallowed.
    • No schema change to .gwm.toml - purely an additive layer above the existing bootstrap pipeline. Existing repos get a single one-shot prompt after upgrade.
    • Full docs page: docs/4.configuration/5.trust-ledger.md (shipped in PR #115 - closes #114).
  • 🔒 bootstrap refuses to copy through symlinks at the destination (#93 / #110). Three changes that together close a write-anywhere primitive triggered by gwm bootstrap on an attacker-controlled checkout:
    • Reorder: run_no_symlinks now runs before run_copies in bootstrap::run. The previous ordering let a target declared in both [[bootstrap.no_symlink]] and [[bootstrap.copy]] be touched by the copy pass first - either silently skipping through a live symlink or writing through a dangling one before the no-symlink pass got a chance to strip it.
    • Defence in depth in run_copies: a new symlink_metadata check at the top of the loop refuses any step whose destination is a symlink (broken or live), regardless of whether the user declared [[bootstrap.no_symlink]] for it. Closes two failure modes the reorder alone doesn’t: symlinks not declared in [[no_symlink]] (planted by manual migration, editor plugins, or an attacker), and the macroscopic TOCTOU window between the no-symlink pass and the copy loop.
    • O_NOFOLLOW-based write primitives (bootstrap::copy_no_follow / bootstrap::write_no_follow) replace every fs::copy / fs::write site under run_copies, resolve_missing (inline fallback), and handle_guard_match (seed-from-example). The destination file is opened with O_NOFOLLOW | O_CREAT | O_EXCL on unix, so even if a symlink materialises in the microseconds between the stat and the open, the syscall returns ELOOP (symlink) or EEXIST (other entry) instead of writing through. Source permissions are preserved on unix to match the prior fs::copy behaviour.
    • Observable contract change: a copy step whose destination is a symlink now reports Failed with a message naming the path and referencing #93. Pre-fix, the live-symlink case was a silent Skipped and the broken-symlink case was a Failed from fs::copy errno. No CLI surface or .gwm.toml schema change.
  • 🔒 bootstrap rejects path traversal in copy / guard / fallback fields (#94 / #111). Closes the write-anywhere / read-anywhere primitive flagged on step.to, Guard.example_file, and FallbackContent.target - values like "../../etc/passwd" or "/etc/shadow" previously slipped through Path::join and landed outside the worktree (or read outside the main repo). Two layers:
    • Load-time validation (Config::load_for_repo → validate_bootstrap_paths): rejects empty strings, absolute paths, and .. traversal segments in the three fields, surfacing the violation with the exact TOML key (e.g. bootstrap.copy[0].to) so the user can fix the config without grepping. bootstrap.copy[].from is intentionally not validated - it’s joined onto ctx.main_repo (the repo root that ships the config), same trust boundary as the file itself.
    • Runtime defence-in-depth (bootstrap::ensure_within): canonicalize-then-prefix-check on every resolved path in run_copies (against ctx.worktree) and on example_file in handle_guard_match (against ctx.main_repo). The deepest existing ancestor is canonicalized so the check catches .. traversal, absolute paths, AND symlinks in intermediate components - closes a third attack vector the load-time validator alone misses (an attacker who can plant a symlink at a dst parent component redirects the resolved path even when the TOML string is benign).
    • Behavior on rejection: the step reports Failed with a detail that names the offending path and references issue #94. No CLI surface or .gwm.toml schema change for benign configurations.
  • 🔒 config rejects invalid [[bootstrap.guard]].deny_patterns at load time (#96 / #116). A guard whose regex failed to compile previously surfaced the error mid-bootstrap on the first matching copy - leaving an inconsistent partial state on disk. Validation now runs at Config::load_for_repo, naming the offending bootstrap.guard[<N>].deny_patterns[<M>] index plus the regex crate’s diagnostic. The bootstrap-time evaluation path also flipped from “log and continue” to fail-closed: a regex that somehow slips past the load-time check (e.g. a config edited between load and use) makes the affected copy step report Failed instead of proceeding without the guard.
  • 🐛 R: review no longer silently no-ops on push-tracked PR branches (#117 / #118). resolve_review_base now ignores branch.<n>.merge when it points at the branch itself. After the canonical gwm create <type> <#> <slug> && git push -u origin <branch> flow, git records merge = refs/heads/<same-branch> - the local branch tracks its own remote-side copy. The previous priority-1 chain step returned that name verbatim, making git diff <branch>..<branch> empty so the launcher’s default skip_when_no_changes = true silently swallowed the R keystroke. The fix falls through to the next chain step (branch.<n>.gwm-base → [review].default_base → dev / main) when the upstream is self-referential, so the launcher hands git diff a ref that actually diverges from HEAD. The self-equality compare runs after read_branch_merge strips the refs/heads/ prefix, so both the canonical refspec form and the bare short-name form are caught.
  • ✅ E2E coverage for the mutating subcommands (#101 / #109). tests/cli_binary.rs now exercises gwm init (default body shape, idempotency refusal on existing .gwm.toml, repo-bound contract), gwm create (worktree dir + branch creation at HEAD, branch.<name>.gwm-base recorded for the launcher fallback chain, [[bootstrap.copy]] runs by default but is skipped under --no-bootstrap, validation rejects unknown branch types and non-digit issue numbers), and gwm remove (deletes the worktree dir, --delete-branch drops the local branch, unknown patterns fail loudly). All worktree-creating tests pin [worktree].base to a tempfile::TempDir so the CI runner never writes under ~/cc-worktree/....
  • ✅ Characterization tests for #98 / #99. tests/worktree_integration.rs::add_silently_attaches_to_pre_existing_stale_branch pins the current (buggy) reuse behaviour of worktree::add when the target branch already exists - a fix for #99 will turn this test red and force the reviewer to confirm the contract change. remove_prunes_admin_files_on_happy_path pins the post-condition that .git/worktrees/<name> is gone after a successful worktree::remove - the post-condition that #98’s reorder fix must preserve.
  • Add sha2 = "0.10" for .gwm.toml content hashing in the TOFU trust ledger (#95).

Historical delta: [0.7.0-rc.2] - 2026-05-23

Section titled “Historical delta: [0.7.0-rc.2] - 2026-05-23”

Delta against v0.7.0-rc.1. Merged PRs: #119, #120, #121, #122, #129, #130, #131, #132, #133, #134, #135, #136, #137.

This RC bundles a TUI render-loop perf cleanup, three targeted bug fixes (#98, #99, #100), an MSRV bump to 1.82, two error/dedup refactors (#105 / #106), and the complete decomposition of the tui::app::App god struct into six sub-state slices under tui/state/ (#102, parts 1/6 through 6/6 - #123, #124, #125, #126, #127, #128).

  • ⚡ TUI no longer opens git2::Repository per worktree row per frame (#103 / #129). branch_age_for(w) used to call git2::Repository::open(&w.path) + a full revwalk on every render pass; on a 30-worktree repo at 60 FPS that’s 1800 opens + 1800 revwalks per second, measurable as a sidebar j/k stutter. The fix pre-computes age once at worktree::list() time and caches it as WorktreeInfo.age: Option<Duration> - the TUI render path becomes pure read-only struct field access. The list pass already opens the worktree’s repo to read HEAD + status, so piggybacking the revwalk costs nothing extra; the per-frame work disappears entirely. Unblocks the tui::app::App decomposition (#102) by removing the libgit2 leak from the render tree.
  • 🔒 Argv-injection guards on gh label … and git diff / git rev-list (#100 / #121). Closes two vectors flagged by the multi-agent review:
    • Label names that confuse gh’s flag splitter. gh label create <name> takes the name positionally, so a [[labels]] name = "-h" was parsed by gh as the help flag - gh printed its banner and exited 0, while gwm labels push happily reported ✓ created for an entry that never existed. name = "--repo" retargeted to a different repository entirely. validate_label_name (in src/labels.rs, invoked both at Config::load_for_repo time and from resolve_labels) rejects empty names, leading -, embedded , (GitHub’s label-list separator), and ASCII control characters. Spaces and unicode pass through verbatim - GitHub permits them and real-world label sets rely on them.
    • --end-of-options before user-derived git refs. materialise_diff and count_commits_ahead shelled out to git diff <base>..<head> and git rev-list --count <base>..<head> with base and head taken verbatim from the review base-resolution chain (branch.<n>.merge, branch.<n>.gwm-base, [review].default_base). A [review] default_base = "--upload-pack=/tmp/x" is the textbook CVE-2017-1000117 shape: on susceptible git versions the leading -- made git re-parse the value as an option and (historically) execute arbitrary code. Modern git is patched, but the defensive --end-of-options separator is still mandated. New helpers git_diff_argv / git_rev_list_count_argv centralise the argv construction with the separator interposed; the two shell-out sites consume them.
    • Covered by tests/labels_tests.rs::{validate_label_name_*,resolve_labels_propagates_invalid_name_from_config}, tests/config_tests.rs::labels_load_rejects_leading_dash_name_at_load_time, and tests/launcher_tests.rs::{git_diff_argv_*,git_rev_list_count_argv_*,count_commits_ahead_treats_dash_prefixed_base_as_ref_not_flag}. No CLI surface or .gwm.toml schema change for benign configurations.
  • ♻️ TUI: tui::app::App god-struct fully decomposed into six tui/state/ sub-structs (#102, parts 1/6 through 6/6). The 1300-line App previously held 30+ pub fields mixing repo handle, domain state, UI state, input state, modal state, and async fetch state - every change rippled across unrelated fields. This RC lands all six atomic extractions:
    • CreateForm (#123 / #133, 1/6) - the 4 create-overlay fields (create_field, create_type_index, create_issue, create_desc) + 7 inline methods become a pure CreateForm sub-struct at src/tui/state/create_form.rs. Field enum re-exported from tui:: so the public surface is unchanged. New tests/tui_state_create_form_tests.rs (11 unit tests).
    • FilterState + memoised filtered_indices (#124 / #134, 2/6, closes #104) - extracts the fuzzy-filter buffer + active flag, AND introduces the memoised cache that closes #104. App::filtered_indices() used to recompute 3–5× per render frame (table draw + clamp_selection_to_filter + navigation methods); now the expensive nucleo pass runs once per query/list change. Cache invalidated by every buffer mutation; worktrees-length mismatch auto-invalidates as defence-in-depth. 17 new unit tests.
    • ConfirmModal (#125 / #131, 3/6) - the destructive-action safety countdown (issue #30) becomes a pure sub-struct parameterised on (now: Instant, total: Duration). ConfirmKeyAction / CountdownTickOutcome enums move alongside. 10 new unit tests.
    • LinkPrompt (#126 / #135, 4/6) - the two-stage issue/PR link prompt (issue #67) becomes a pure sub-struct exposing toggle_target / commit_target / stage-aware push_char / pop_char. LinkPromptStage re-exported. 8 new unit tests.
    • SidebarState + navigation-triple dedupe (#127 / #136, 5/6) - extracts the sidebar fields + collapses the 4-occurrence sidebar_scroll = 0; invalidate_sidebar_cache(); refresh_link(); triple into one App::on_navigation() entry point. Future navigation motions can’t accidentally regrow the duplication. 13 new unit tests + 1 App-level integration test.
    • GitHubFetch + inflight dedupe (#128 / #137, 6/6) - extracts the 4 GitHub-linking fields (link, link_slug, issue_state, pr_state) + the GitHubFetchState<T> enum. New explicit inflight-dedupe layer: every spawn goes through GitHubFetch::request(FetchKey) returning HitCache / AlreadyInflight / Spawn. Concurrent visit events to the same target no longer trigger redundant gh shell-outs. 11 new unit tests.
  • ♻️ naming.rs regexes are now module-level LazyLock<Regex> statics (#97 / #122). The three patterns powering BranchSpec::validate_against and parse_branch (^\d+$ for issue numbers, ^[a-z0-9][a-z0-9-]*$ for kebab-shaped descriptions, ^([a-z]+)/#(\d+)-([a-z0-9-]+)$ for the full branch shape) used to recompile per call via Regex::new(...).unwrap() / .ok()?. The TUI sidebar refresh and gwm doctor both call parse_branch once per worktree - O(N) wasted compilations on a busy repo. The static-lift drops the per-call cost from ~5µs to ~50ns. Compile-time literals can take expect("static <NAME> compiles"): a regex-compile failure on a hard-coded pattern is a developer bug caught by the new naming_regexes_compile_at_first_use test, not a user-facing error. CLAUDE.md “no unwrap on user-facing paths” rule respected - three .unwrap() call sites removed.
  • ♻️ Split GwmError::Other(String) catch-all into typed variants (#105 / #130). The four highest-recurrence patterns flagged by grep GwmError::Other src/ now have their own variants so callers (and downstream pattern-matchers) can introspect without string-sniffing:
    • UnbornHead { reason } - cli.rs::current_branch when repo.head() fails (unborn / detached) or the shorthand can’t be resolved.
    • GhJsonParse { kind, source } - every serde_json::from_str site that consumes a gh CLI payload (parse_issue_json, parse_pr_json, find_pr_for_branch’s PR list, parse_labels_json, parse_milestones_json). kind is a &'static str so a grep-friendly hint lands in Display; source is the underlying serde_json::Error for #[source] chaining.
    • LinkMissing { kind: LinkKind, branch } - cmd_open when a branch has no recorded issue or PR link. LinkKind (a new public enum Issue | Pr) replaces the prior two Other(format!("no issue/PR linked …")) sites with a typed dispatch.
    • The 6 Other(format!("git log/status …")) sites in worktree.rs::git_log_with_author / git_log_oneline / git_status_short now use the existing CommandFailed(String) variant, aligning them with the rest of the gwm-shells-out callers (issue, PR fetches, multiplexer spawn).
    • Remaining Other(String) call sites (config-key parse failures, unknown gh enum states, ambiguous fuzzy patterns, trust-prompt refusals, TUI input validation, missing origin remote) stay as Other for now - they’re heterogeneous enough that grouping them under one new variant would be a regression of the same shape.
  • ♻️ StepResult constructors, StepStatus::sigil helper, shared LinkTarget, and generic diff-summary helpers (#106 / #132). Four real duplication patterns flagged by the multi-agent review collapse here:
    • bootstrap::StepResult now exposes ok / ok_with_detail / skipped / warning / failed associated constructors. The 20-plus inline StepResult { label, status, detail } literals scattered across run_copies, resolve_missing, handle_guard_match, run_no_symlinks, and run_commands collapse into single-line calls.
    • bootstrap::StepStatus::sigil() centralises the Ok → "✓", Skipped → "·", Warning → "!", Failed → "✗" glyph mapping previously duplicated between cli::print_report and tui::ui::render_bootstrap.
    • cli::LinkTarget is the single canonical definition (with clap::ValueEnum); tui::app re-exports it. Values crossing the cli/tui boundary no longer need conversion.
    • labels::diff_summary_line and labels::diff_dry_run_line render the two summary lines shared by gwm labels push and gwm milestones push. Both cli::print_labels_diff and cli::print_milestones_diff now call the helpers verbatim.
    • No CLI / TUI / .gwm.toml schema change - call-site refactor only. Output is byte-identical to v0.7.0-rc.1.
  • 🔧 MSRV bumped to Rust 1.82 (#122). The crate already uses std::iter::repeat_n (stable since 1.82) in src/tui/ui.rs’s countdown bar; the Cargo.toml rust-version field now declares the actual floor that compiles the whole crate. std::sync::LazyLock (1.80, from #97) is covered too. CONTRIBUTING.md already documented “stable channel, 1.80+ - verified on 1.89”; users on 1.80 / 1.81 were broken regardless.
  • 🐛 worktree::remove prunes admin metadata BEFORE deleting the directory (#98 / #119). Closes a “phantom worktree” failure mode: worktree::remove previously called fs::remove_dir_all first, then wt.prune. A failure on the prune step (libgit2 error, permissions, partial state) left the on-disk directory gone but repo.worktrees() still listing the name - gwm list showed a ghost row, gwm path resolved to a non-existent directory, and gwm bootstrap against the ghost failed with a confusing error until the user ran gwm prune manually. The new ordering prunes first (administrative metadata) and only then removes the filesystem tree, so any failure leaves the worktree visibly present on disk and recoverable through the normal gwm surface. Symmetric to the bootstrap reorder shipped in #93: when a multi-step operation can partially fail, the step that’s hard to recover from goes first. Covered by tests/worktree_integration.rs::remove_failed_filesystem_unlink_still_prunes_metadata (forces remove_dir_all to fail via a read-only parent dir and asserts the admin entry is already gone) plus the existing remove_prunes_admin_files_on_happy_path characterization. No CLI surface or .gwm.toml schema change.
  • 🐛 gwm create refuses to silently reuse a stale local branch (#99 / #120). Closes a “I asked for fresh, got stale” foot-gun: when a local branch with the same canonical name already existed (left over from an aborted attempt, a git fetch, or a deleted-and-recreated issue), worktree::add silently attached the new worktree to that branch’s tip - resurrecting whatever commit it pointed at, invisible to the user until they ran git log inside the new directory. The new default surfaces GwmError::BranchExists { name, oid } naming the stale tip so the user can audit, delete the ref, or re-run with --reuse-branch. The escape hatch (--reuse-branch on the CLI, reuse_branch: true on the lib) keeps the legacy attach-to-existing-tip behaviour available for the rare case where that is the intent. Covered end-to-end by tests/cli_binary.rs::{create_refuses_stale_branch_without_reuse_flag,create_reuses_stale_branch_with_flag} and at the libgit2 layer by tests/worktree_integration.rs::{add_refuses_stale_branch_without_reuse_flag,add_attaches_to_stale_branch_with_reuse_flag}. BREAKING CHANGE for in-tree lib consumers: worktree::add now takes a reuse_branch: bool parameter (recommended default: false).

Historical delta: [0.7.0-rc.3] - 2026-05-23

Section titled “Historical delta: [0.7.0-rc.3] - 2026-05-23”

Patch RC over 0.7.0-rc.2 - one correctness fix on GitHubFetch (the #128 extraction) plus two encapsulation polishes flagged by Copilot review on the rc.2-era extraction PRs (#131 ConfirmModal and #134 FilterState). A throwaway clippy chore + a per-frame allocation drop in the filter bar are bundled in.

  • ⚡ Filter bar render: zero-alloc per frame (src/tui/ui.rs:102). Span::raw(app.filter.query().to_string()) allocated a fresh String on every render tick (~5/s during typing, every 200ms otherwise). Since query() returns &str and Span::raw accepts Into<Cow<'_, str>>, pass the borrow directly. Surfaced by Copilot review on PR #141.
  • ♻️ ConfirmModal.started_at is private; is_armed() -> bool accessor added (#131). Encapsulation polish from a Copilot review on PR #131 (the original ConfirmModal extraction in rc.2). The single external read site (src/tui/ui.rs rendering the safety-countdown bar) was migrated; no behaviour change. Test path stays through the public API (progress(now, total) == 0.0 proves “armed at exactly now” - no #[cfg(test)] back-door needed). Pinned by is_armed_returns_true_after_first_press_y_and_false_after_second.
  • ♻️ FilterState.query is private; query() -> &str accessor added (#134). Copilot review on PR #134 flagged the pub query: String field as leaking the buffer across the module boundary - every external caller did .is_empty(), .len(), or format!("... {}", q), all equally well served by a &str accessor. Privatising also funnels the full write surface through push_char / pop_char / set_query / clear, each of which maintains the cache-invalidation contract that the field-write path bypassed. 8 read sites migrated across src/tui/{mod,ui,app}.rs; 32 test sites switched to set_query() / query(). Pinned by query_accessor_reflects_push_char_and_pop_char.
  • ♻️ tui::fuzzy_match_indices re-export dropped (#134). The helper was promoted to tui:: even though the only in-crate consumer (tui::app::App) already imports it via the full tui::state::filter::fuzzy_match_indices path. The re-export widened the public surface by one symbol for no return. Trimmed to pub use state::filter::FilterState;.
  • 🔧 Field / LinkPromptStage use #[derive(Default)] + #[default] attribute instead of hand-written impl Default. Pre-existing on dev from #123 / #126; flagged by clippy 1.95’s derivable_impls lint after CI runner toolchain bump. Net: −16 lines, identical semantics. Folded into PR #139 to unblock CI green.
  • 🐛 GitHubFetch cache keyed by issue/PR number + drops late results after invalidate() (#138). Closes two correctness bugs flagged by Copilot review on PR #137 (the rc.2 #128 extraction PR), both in the new GitHubFetch sub-struct:
    • Cache identity collision. is_cached looked only at the per-target issue_state / pr_state enum, so any terminal variant for Issue(_) made every Issue(*) key falsely hit the cache. After request(Issue(42)) → complete_issue(42, Ok(...)), a subsequent request(Issue(43)) wrongly returned HitCache - even though Issue 43 was never fetched. The dedupe contract promised “(target, number) is the identity” - it held on the inflight path but broke on the cache path.
    • Late-result race with invalidate(). complete_issue / complete_pr stamped results unconditionally, even when an intervening invalidate() had cleared the inflight slot. A shell-out that resolved after the user navigated away (and invalidate() cleared the slot) would stamp stale Loaded(IssueStatus) into the now-active worktree’s cache.
    • Fix: replace the single issue_state / pr_state slots with issue_cache: HashMap<u64, GitHubFetchState<IssueStatus>> + pr_cache: HashMap<u64, GitHubFetchState<PrStatus>> keyed by number; complete_* early-returns when the inflight slot for the key is gone (the “still authoritative” check). invalidate() clears both maps AND the inflight set. New keyed accessors GitHubFetch::issue_fetch_state(number) / pr_fetch_state(number) return a &'static GitHubFetchState::Idle for absent keys (no per-call allocation). App-level App::issue_fetch_state() / pr_fetch_state() resolve via the current link.issue / link.pr so the renderer call sites in tui/ui.rs work unchanged.
    • Pinned by tests/tui_state_github_fetch_tests.rs::{request_after_complete_for_different_number_returns_spawn_not_hit_cache, request_after_complete_for_different_pr_number_returns_spawn_not_hit_cache, complete_after_invalidate_drops_the_stale_result, complete_pr_after_invalidate_drops_the_stale_result} - all four flipped RED → GREEN. No CLI surface or .gwm.toml schema change.

No CLI surface change. No .gwm.toml schema change. Library surface shrinks by one re-export (tui::fuzzy_match_indices) and two pub fields (ConfirmModal.started_at, FilterState.query) - external consumers of gwm as a lib would need to migrate to the accessors, but the lib is not yet on crates.io so practical impact is zero.

Historical rc.3 install note: cargo install gwm --version 0.7.0-rc.3 or cargo install --path . from a clone. No data migration. No trust ledger changes - existing ~/.config/gwm/trusted_bootstraps.json entries from rc.2 continue to apply.

MSRV stays at 1.82 (no toolchain bump).