Skip to content

v1.11.0

Latest

Choose a tag to compare

@github-actions github-actions released this 26 Oct 18:23
· 17 commits to main since this release
v1.11.0
b060d39

[1.11.0] - 2026-10-26

Security

  • cargo audit failed on dev itself: the YAML parser under the crate is
    unmaintained
    (#688).
    RUSTSEC-2024-0449 marks unsafe-libyaml unmaintained: the repository was
    archived on 2024-03-25, and the crate is a machine translation of libyaml's C
    into unsafe Rust, so bugs found in it are not fixed upstream. It reached this
    crate through serde_yaml_ng 0.10.0, a direct dependency, and the advisory
    entered the database after this repo's last green cargo audit, which is why
    a check that had been green went red without a line of code changing.

    Replaced by the pair the advisory itself names, both written in safe Rust:
    serde-saphyr for the one typed parse in src/, and saphyr for the
    dynamic tree the workflow guards read. unsafe-libyaml is gone from the
    graph entirely rather than accepted through an ignore in audit.toml,
    which ci.yml allows with a rationale (#340) and which #688 offered as the
    other outcome.

    The other candidates, and why each was not taken. serde_norway depends on
    unsafe-libyaml-norway, the same translated C under a new name, which
    silences the advisory by renaming the crate rather than by fixing the cause.
    serde_yml carries RUSTSEC-2025-0068, unsound, with a segfault reachable
    through its serializer, patched = [] and the project archived, so that one
    is a disqualification.

    noyalib, the advisory's second suggestion, is not disqualified, and the
    first draft of this entry said it was: it carries RUSTSEC-2026-0333, but
    that advisory is patched = [">= 0.0.53"] and the current release is 0.0.58,
    so a current noyalib is clean. What it is instead is a judgement: it was at
    its 58th patch release before a 0.1, it took a denial-of-service advisory two
    weeks before this was written, and serde_yml, from the same author, is the
    archived unsound crate above. serde-saphyr is the advisory's first
    suggestion and the one taken.

    A fourth route was measured and dropped, and is worth recording because it
    looked free: deserialising into serde_json::Value through serde-saphyr
    would have been a near-mechanical rename of every read the swap touches,
    the bulk of them in release_workflow_tests.rs. No count is given because
    three attempts at one were each wrong and none of them credited a guard with
    anything: a figure derived in prose drifts exactly like the floor it
    describes, with nothing to catch it. It sorts mapping
    keys, so string_keys, which promises document order, would have lost that
    guarantee while its own test kept passing on a sample that happens to be
    alphabetical. It also reads the YAML key true: as the string "true",
    which leaves string_keys unable to fire at all and turns the guard from
    #673 vacuous. Both measured before the route was abandoned.

    tests/workflow_reader_tests.rs pins what the new reader answers, including
    the one semantic that was inherited rather than chosen: an absent key and a
    key whose value is YAML null read alike, because the old reader could not
    tell them apart and every must carry no X: assertion was written against
    that. While both crates were declared the two readers were compared head to
    head, against the accessor that actually ships: 187 comparisons over 17
    documents, zero divergence
    on is_null(), as_str(), as_bool(),
    sequence length and mapping size, including five-level paths where the old
    reader cascaded to null. That is the scope of the number and not a claim
    about every behaviour. An earlier measurement covered an adapter that was
    replaced before this shipped, which is why it was redone rather than quoted.

    Two behaviours changed, below. Separately, a form that repeats a key, even
    one gwm reads none of (a second name:), no longer parses, where
    serde_yaml_ng let it through. Two paths parse a form and they fail it
    differently. gwm new refuses it, in serde-saphyr's wording, as it does
    any form it cannot parse. gwm create --issue and the TUI's prefill
    from an issue parse the whole form to take its title:, and treat a form
    they cannot parse as having no prefix: unless the type's config sets
    title_prefix, the whole issue title lands in the slug
    (feature-add-config-types rather than add-config-types).

    A YAML merge key now contributes its fields. serde_yaml_ng did not
    resolve <<:, so an issue form factoring fields out through an anchor lost
    the optional ones with no error, or was rejected when the merge carried a
    required one such as type; serde-saphyr resolves merge keys, a YAML 1.1
    type. gwm new renders the merged fields into the issue it opens.
    gwm create --issue and the TUI's prefill move the other way: a form
    rejected for a type its merge carried used to contribute no prefix, and
    now contributes its title:, so unless the type's config sets
    title_prefix the slug drops the prefix it used to carry. None of the forms
    this repo ships uses an anchor, so nothing moves here today, and it is
    pinned as a contract rather than left to be discovered the first time
    somebody factors two forms. Resolution is bounded by the reader's default
    alias budget, so a hostile form cannot expand without limit.

    Mapping equality is now sensitive to key order, which is test-side only.
    MappingOwned is a LinkedHashMap comparing element by element, where the
    old Mapping wrapped an IndexMap and compared order-insensitively, so
    swapping two lines of a pinned env: now reddens the guard that pins it.
    Stricter is the right direction for a step pinned as written, and it is what
    the by-value guards of #647 and #665 were reaching for, but it is a change
    rather than a no-op: a reordering that alters nothing at run time now has to
    be acknowledged in the expected tree.

  • Two of the four workflow permissions: grants were compared by nothing
    (#681).
    ci.yml (#677) and release.yml (#669) each had a test reading their grant
    by value. docs-sync.yml and pre-release.yml were covered only by the
    repo-wide sweep that asks for the key to exist, which reads that something is
    declared and never what: permissions: write-all on either file, alone, left
    all 32 tests of the suite green. Measured while reviewing #677, and older than
    it, so it was filed rather than folded in.

    Both are now pinned by value. docs-sync.yml must grant {}, the strongest
    statement in this repo and the one an edit widens most easily, since it is
    triggered by a push to main and authenticates its one API call with a PAT
    that is not the workflow token. pre-release.yml must grant
    contents: write for its publish, and is held to the same split
    release.yml has: only build and release may inherit that grant, any
    other job must carry contents: read. There is no such job today, which is
    why the rule is written before one appears rather than after.

    Compared as parsed values and never through as_str(), which reads None
    for {} exactly as it does for a key that is not there, so a reader asking
    for a string cannot tell the repo's strongest grant from no grant at all.
    write-all is the other half of the same cause and not the same trap: it is
    a string, reads Some, and defeats a presence check instead, which is how
    it passed the existing sweep (#669). Four mutations, each applied alone:
    both grants flipped to write-all, a scope written on docs-sync.yml's
    only job, and a third job added to pre-release.yml after the publish with
    no grant of its own.

  • ci.yml declared no permissions:, so its nine jobs took whatever the
    repository setting said

    (#677).
    default_workflow_permissions reads read today, so the token was
    harmless, by a setting no pull request shows rather than by anything the
    workflow said. Every other workflow here states its own contract:
    docs-sync.yml carries permissions: {}, release.yml and
    pre-release.yml carry contents: write, and the two jobs that run after
    release.yml's publish carry contents: read (#669).

    What a flip of that setting to write would hand a pushing token to, on
    every pull request, is the build scripts and proc macros of the whole
    dependency graph, cargo install cargo-audit --locked building from
    source, and five third-party actions, one of which copies the token to
    disk: cachix/install-nix-action@v31 falls back to GITHUB_TOKEN with no
    github_access_token input and writes access-tokens = github.com=<token>
    into /etc/nix/nix.conf (install-nix.sh at that tag, lines 50-52,
    installed at line 93), which puts back for the rest of the job what
    persist-credentials: false keeps out of the git config (#433).

    ci.yml now grants contents: read at workflow level, which is all any of
    its jobs does anything with: no step of the file names a token of its own.
    Some hold the default one regardless, actions/checkout through its token:
    default and the nix action through its fallback, and scoping that default is
    what the grant is for. A test pins that grant by value
    and refuses a job that restates a different one, since a job-level
    permissions: replaces the workflow's wholesale rather than narrowing it.
    A second, deliberately weaker sweep asks every workflow in the directory to
    declare something, {} included, at the workflow level or on every one of
    its jobs, so a workflow added later cannot arrive silent the way this one
    did. Eight mutations, each applied alone: the grant removed, turned to
    write, given a second scope, restated wider on a job, emptied on a job,
    jobs: {}, a new workflow with no permissions:, and one workflow file
    short of the floor.

    The grant holds only while the file stays token-free, so that premise is
    asserted rather than written down: the whole parsed workflow is refused the
    words token and secret, in any casing. Review walked the first version of
    that search, three keys inside steps:, and found run: the only one any
    real input exercised, a with: dropped from the list staying green, and
    every job-level surface unread, container:, services: and a reusable
    call's secrets: inherit among them. Refusing the words rather than their
    spellings closes github['token'] and toJSON(secrets) too, which are the
    next two forms and not the last. Nine synthetic shapes are asserted to be
    seen, since ci.yml carries none of them, and six mutations of the real file
    each name the word they added.

    The token on disk is not what permissions: removes, only what it scopes:
    the action writes it either way. Not giving the action a token at all is the
    only thing that removes it, at the cost of an unauthenticated nixpkgs
    fetch, rate-limited per runner IP.

  • The jobs that run after the release publish no longer hold a write
    token
    (#669).
    release.yml grants contents: write at the workflow level for the
    publish, and every job inherited it. homebrew-tap-update and
    scoop-bucket-update run after the publish with that token and a
    GH_TOKEN in one of their steps, so one added line, gh release edit
    with empty notes, could undo the notes #665 pins, with every guard green.
    Neither writes to this repository: the token checks out the sources and
    downloads a published checksum, and the push to the tap or the bucket
    goes through the PAT of the job's second checkout, which permissions:
    does not govern. Both now carry permissions: contents: read.

    A test sweeps the jobs of release.yml: each carries exactly contents: read, compared by value, unless it is build or release, the two
    allowed to inherit the write token. A job added later without
    permissions: inherits write by default, which is how these two got it,
    so it arrives red. build keeps its token, out of this issue's scope: it
    finishes before the publish starts, so it cannot undo the notes of the tag
    being released, which the publish writes after it. It can still edit the
    notes of an earlier release, and restricting it is the same one-line
    change.

    What no test can check is whether contents: read is enough at run time.
    Both jobs are continue-on-error: true, so a missing permission would
    leave the release green and the tap and the bucket silently stale: the
    v1.11.0 tag is the first run that checks it.

Added

  • Repo groups fold in the workspace worktrees pane
    (#680). In --workspace
    mode the pane merges every repo's listing into one table, grouped by repo
    with each main worktree first, and nothing marked the groups beyond a
    repeated REPO cell. Each repo is now an accordion headed by its main
    worktree row: Left folds the cursor's group down to that header, Right
    unfolds it (collapse_group / expand_group under [tui.keys], both keys
    unbound in the list view before). The header carries a chevron in its age
    cell, ▾ open and ▸ folded, a slot a trunk branch leaves empty, and a
    folded header paints in the muted role so a folded repo reads as folded
    at a glance.

    The fold lives in the one index space that selection, marks and the
    counter resolve against, not in the renderer: hiding rows at draw time
    would let d delete a row other than the one under the cursor. Folding
    moves the cursor onto the header and drops the marks it hides. An active
    filter overrides the fold, since the query matches worktree names and can
    keep a linked row while filtering its header out; under a query the two
    keys are inert rather than deferred, and clearing the query drops the
    marks the returning fold hides. The filter cache is keyed
    on query and list length, which a fold changes neither of, so a toggle
    invalidates it explicitly; without that, a warm cache served the pre-fold
    rows and the fold did nothing, silently, on exactly the rendered path.

    The REPO column ceiling goes from 24 to 32 cells: the longest repo
    basename on a real workspace root was exactly 24, so one more character
    truncated the name the column exists to show. Session-only state, nothing
    persists it; single-repo mode is untouched.

  • gwm doctor reports the config a deleted branch left behind, and
    --fix drops it

    (#633). gwm create
    writes about seven branch.<name>.gwm-* keys per branch and nothing
    removes them when the branch goes away, so .git/config only grows. That
    is not cosmetic: libgit2 reparses the file on every Repository::open,
    and statuses() and branch.upstream() each take a config snapshot that
    duplicates every entry, so gwm list pays the file's size once per
    worktree, several times over. With 8 worktrees held constant and the
    config as the only variable, the listing goes from 45 ms at 13 lines to
    184 ms at 1434.

    The sweep matches the branch.<name>.gwm- prefix rather than a list
    of known key names, so a key added in a later release is covered without
    anyone having to remember. Keys git itself writes (remote, merge, …)
    are never touched, on a live branch or a dead one.

    The report separates keys --fix can drop from keys reached through
    include.path, which live in a file gwm does not rewrite: for those the
    hint names the only remedy that works, editing that file, instead of
    pointing at a --fix that has already failed on them and warning again
    every run.

    --fix is opt-in because it edits .git/config; a plain gwm doctor
    stays read-only. It reports what it achieved by reading the config back
    after writing, not by counting the deletions it attempted: a key pulled in
    by include.path lives in a file gwm does not rewrite, and libgit2
    refuses to delete it, so those are named as survivors instead of being
    claimed as purged. A purge that cannot run at all does not take the
    report down with it either: doctor exists to diagnose, so the checks
    still print. It works on the local config level alone, since deleting
    through the merged view would let git resolve the key up to
    ~/.gitconfig. It leaves the empty [branch "…"] header behind, because
    libgit2 removes keys and not sections: on a repo grafted to 1434 lines,
    purging 869 keys brought the file to 561 lines, not to 13. That figure is
    measured, not 1434 - 869: a multi-valued key spans several lines, and
    the keys of branches that are still alive stay put.

  • The mouse gwm was already capturing now does something, and two hidden
    panels get a place on the header

    (#624). EnableMouseCapture
    went out on the first frame and no Event::Mouse arm ever existed, which is
    worse than not supporting the mouse at all: capture takes the terminal's own
    drag-to-select away, so the cost was being paid and nothing was bought with
    it.

    Clicking now selects the worktree row under the pointer, focuses the pane it
    lands in, picks a row in any modal listing, switches a Settings tab, or
    closes a modal through the ✕ in its corner. The wheel scrolls — or moves
    the selection of — whatever is under the pointer, which is a deliberate
    departure from the issue's "the focused list": pointing at a pane is how a
    pointer says which pane it means. Focus stays what Tab and the digits set.

    The header carries ▤ and ⚙ left of the pinned version chip, opening the
    Command Logs and Settings panels the way 3 and 4 do. They are the only
    thing on screen saying those panels exist — until now the help overlay was
    the only way to find out — so they are reserved ahead of the working path in
    the row's sacrifice order.

    The geometry every click resolves against is published by the renderer
    as it draws, not re-derived afterwards: the layout is rebuilt from scratch
    each frame, so a click target computed from App state drifts the first
    time a rule changes, and the row-arithmetic rules here have changed twice
    already. A surface that is not on screen publishes no zone and therefore
    cannot be hit, which is what keeps the hit test free of any view branching.

    M releases the mouse outright, handing the terminal's text selection back
    until it is pressed again — and the release survives a trip through lazygit,
    an exec run or a review launcher, which used to re-enable capture on the
    way back. Shift+drag reaches the same selection without giving anything up
    on the terminals that implement it, which is most of them.

    The pointer reaches more than the issue listed, on feedback from using it:
    a sidebar section's title opens that section full size (Issue / PR,
    Agents, Working Tree, Recent Commits — the same modals I, a, W
    and c open), the create and rename forms focus a field on click and step
    the branch-type selector from its ‹ / ›, and the confirmation modal's
    buttons are buttons. The ✕ and the buttons both fire by handing the event
    loop the key the user would have pressed — Esc and the modal keymap's
    activate — so a rebind reaches the mouse and no modal grows a second copy
    of its own teardown.

    [tui] mouse decides whether any of this is on, defaulting to true and
    editable live from the Settings panel's TUI tab. Reading the mouse and
    letting the terminal select text are mutually exclusive — while gwm is reading, a drag belongs to gwm — so M switches
    between the two for a session, [tui] mouse = false starts every session on
    the other side of it, and the header carries a mouse off · M chip while
    it is off — a mode with no sign on screen is one you get stuck in, with every
    click doing nothing reading as a broken build rather than as a switch you
    threw. gwm keeps
    its side of that trade as small as it can: it asks for press tracking
    (1000) only, never the drag and motion reporting (1002 / 1003) most
    TUIs turn on with it, because nothing here reads a drag. Shift+drag is the middle
    ground and does reach the terminal's own selection without giving the click
    up — honoured by the terminal rather than by the application, so its setting
    decides (Ghostty's mouse-shift-capture, iTerm2's "Terminal may report
    mouse events").

    The PTY overlay still drops mouse events, exactly as it did before: nothing
    ever forwarded them to the child, so this is the status quo rather than a
    regression. Real forwarding needs SGR re-encoding against the overlay's
    inset origin and the child's own DECSET state.

  • A guard on the version the captures advertise
    (#631). 17 of the 24 tapes
    open the TUI, whose header paints a gwm X.Y.Z chip, so a set regenerated
    before the version bump advertises the previous release for the life of this
    one. v1.8.0 shipped that way and v1.10.0 repeated it.

    Reading the chip back out of the pixels would need OCR. version-stamp.tape
    instead asks gwm --version through vhs, from the same shell and the
    same PATH every other tape resolves gwm through, generate.sh aborts the
    run when the answer is not the version in Cargo.toml, and commits it to
    docs/_capture/captured-version.txt once the run completes. The new
    docs_assets_tests::captures_were_generated_at_the_manifest_version compares
    the two, so the release PR goes red rather than the docs going stale, and the
    tag cannot be cut from a set that documents another version. The tape reports
    the path as well as the version, and the run refuses to continue unless it is
    the file cargo just built: a stale binary carrying the same version number
    answers a version check perfectly, which is the shape the v1.10.0 near miss
    had. tests/capture_pipeline_tests.rs drives all of that against a throwaway
    repo with stubbed tools, and a companion guard pins the phase order rather
    than leaving it to the prose.

Fixed

  • The non-vacuity floors had stopped tracking what they count, and one file's
    own upkeep rule was not followed

    (#649).
    A floor is the assert!(count >= N) that proves a sweep inspected something
    rather than passing over an empty corpus, which is the defect class this repo
    has hit five times. Six had drifted, read by inflating each to 9999999 and
    taking the counter out of the panic: env_guard_invariant_tests audited
    said 9 against 11, em_dash_guard_tests scanned said 4000 against 5610 and
    visited 30 against 75, release_workflow_tests audited said 8 against
    11, and the two naming_tests token scans said 5 against 7. The issue body
    had listed four; re-measuring the rest of the audit scope found the last two
    rather than trusting that list.

    The env_guard one is the case that matters, because the comment beside it
    states the rule: "the floor moves up when a binary is added, never down
    without saying why". Two env-rewriting binaries were added and it stayed at
    9, so two could have dropped out of the set_var / remove_var audit
    entirely with the count still above the floor. The other
    release_workflow_tests floors were re-measured and are exactly tight, so
    they are untouched: the file holds four besides the raised one, audited >= 4, jobs.len() >= 9, swept >= 4 and swept >= 3, each equal to what the
    tree reads today.

    Eight sweeps over docs/ had no floor at all, or !pages.is_empty(), which
    proves a page was found and not that a line of it was inspected. Each now
    counts what it inspects, at the point of inspection rather than of discovery:
    parsed after description() returns Some, resolved per image reference
    rather than per page. Today
    that reads 54 image references, 40 mirrored pages, 30 assets, 29 tracked
    captures, 25 tapes, 81 descriptions, 1600 same-locale pairs and 11065
    unfenced lines.

    Each floor now names which of two policies it follows, since they are not
    interchangeable: exact for a set of units the repo owns, where a red is an
    edit to acknowledge, and deliberately under by a stated ratio for markdown
    lines and string literals, where every rewrite moves the count. scanned is
    back to the ~82% margin it was written with, having drifted to 71%.

    Reds by shrinking the corpus and never by editing a floor: one binary dropped
    from the env audit, one workflow file removed, one docs section and the tapes
    moved out of the tree.

  • An unclosed code fence in the French docs blinded the two guards that
    follow fences
    (#649).
    docs_callout_tests::container_lines and
    docs_assets_tests::image_targets both skip fenced blocks, because a page is
    allowed to show syntax it does not use, and both toggle on a line starting
    with three backticks. An unclosed fence inverts that toggle for the rest of
    the file: the lines being read start being skipped and the page's real fenced
    blocks start being read. On docs/4.configuration/1.gwm-toml.md that takes
    one page from 854 lines read to 301, not to zero. Either way a :::
    directive or a broken image reference can sit in the half that went unread,
    invisible to the guard that exists to catch it, with the guard green.

    docs/fr/6.development/1.testing.md carried an orphan fence at line 200,
    which is its last line, so nothing was blinded yet. That is how such a line
    survives: at end of file it costs nothing until someone appends to the page,
    and the published French page also showed an unterminated code block. The
    English counterpart ends with the same list and no fence. A new guard asserts
    every page closes what it opens, using the same fence rule as the two
    followers on purpose, since one that recognised fences differently could pass
    while they are blind.

  • The flake could pin its version again with the guard written for it
    green
    (#648). The test
    added for #393 asked whether fromTOML and ./Cargo.toml appeared
    anywhere in flake.nix, and the MSRV read, which binds the same parsed
    Cargo.toml, provides both. version = pinnedVersion; with
    pinnedVersion = "0.3.0-rc.3"; above it, the literal #393 let drift for
    eight releases, passed.

    The guard now reads the right-hand side of every binding whose path is
    version or ends in package.version, several on one line, one nested in
    an attribute set, one spelled "version" =, one with a comment before its
    =, or one continued over several lines included, and requires the
    package.version of the ./Cargo.toml read, bare or interpolated into a
    string, inline or through a name every binding under which is exactly
    builtins.fromTOML (builtins.readFile ./Cargo.toml). A name whose binding
    merely contains the read does not count, since what wraps it can override
    the version. Comments are removed by following the file's strings, so a
    # inside one is text and a comment after the binding quoting an old pin
    is no binding. passthru.tests.version, the nixpkgs smoke test, is not a
    version and is left alone. Eleven mutations of flake.nix, each applied
    alone: the previous guard catches only the bare literal, the new one fails
    all eleven at the same assertion, naming the binding. The inline spelling
    #396 shipped stays accepted, and so do three correct flakes the review
    found refused along the way.

    It is a text guard: it checks those bindings, and nothing traces which one
    the derivation actually receives. A dynamic name (${"version"} = …) or a
    pin.version = … merged into the derivation's arguments (pin // { … })
    can pin the version with the guard green. Four review passes each found a
    new such form, which is the sign that text does not bound Nix, and the
    reason CI now asks nix itself (#672, below).

  • Nothing asked nix which version the flake builds
    (#672). The guard above
    reads bindings by name, and an inherit, a dynamic attribute name or a
    set merged into the derivation's arguments could still hand the package a
    pinned version with it green. A new ci.yml job, flake version (nix eval), installs nix and compares the name and version of the gwm
    package with the package.version of Cargo.toml, both read by nix. Five mutations of flake.nix, each applied alone to a
    copy and run through the job's own script: inherit (pin) version; with
    pin from builtins.fromJSON, inherit (pins) cargoToml;,
    ${"version"} = "0.3.0-rc.3";, buildRustPackage (pin // { … }), and a
    name = "gwm-0.3.0-rc.3"; next to pname. Against 1.10.0 in
    Cargo.toml, nix reports the package as gwm-0.3.0-rc.3 for all five,
    its version still 1.10.0 for the last one alone, and the job fails
    all five; the text guard catches the first two.

    The text guard now reads inherit (src) a; as the a = src.a; it is
    sugar for, so both inherit forms fail it, and
    inherit (cargoToml.package) version; with no version = binding, the
    idiomatic spelling it used to refuse, passes. It stays because it runs
    wherever cargo test does, nix or not. The job evaluates the packages
    only, not the dev shell, whose MSRV assert is a different verdict. It is
    not a required check on main until the branch protection lists it.

  • The flake advertised a package for a platform its nixpkgs no longer has
    (#675).
    flake-utils.lib.eachDefaultSystem exposes x86_64-darwin, and the pinned
    nixpkgs is 26.11, which dropped that platform and throws on import:
    nix eval .#packages.x86_64-darwin.gwm.name answered Nixpkgs 26.11 has dropped support for x86_64-darwin, so an Intel Mac running nix profile install github:kbrdn1/gwm-cli got nixpkgs' refusal in place of gwm, while
    the other three systems answered gwm-1.10.0. The flake now names the
    systems it serves, x86_64-linux, aarch64-linux and aarch64-darwin.
    Intel macOS keeps the prebuilt archive, cargo binstall and cargo install, which the install docs now say. Following nixpkgs-26.05-darwin
    for that one system was the other option: it builds today, that branch
    carrying rustc 1.95.0 against a declared MSRV of 1.95, and it expires with
    the branch at the end of 2026, or at the first MSRV above 1.95.

    The flake job now evaluates every system packages exposes rather than
    x86_64-linux alone, and compares the key set by value, which is what
    turns the next nixpkgs release dropping a platform into a red pull request
    rather than a user's problem. Eight variants of flake.nix measured
    through the job's own script: with eachDefaultSystem restored it fails on
    nixpkgs' refusal, a version pinned for one system alone fails naming that
    system, a shortened list of systems fails with every version correct, an
    emptied packages fails on the missing attribute, and the name =
    override of #672 still fails.

    flake_lists_the_systems_it_serves pins the list as text too, because
    eachDefaultSystem is one word away and puts the platform back without a
    word; each of its four assertions was measured against its own mutation.
    Text is the weaker half of the pair and stays the cheap one: with the three
    names kept in a binding and the eachSystem list cut to one system, that
    test is green and the job red, measured both ways.

  • Two workflow guards skipped a key the YAML parser reads as a boolean
    (#673). serde_yaml_ng
    reads true: and false: as booleans, where GitHub Actions reads the
    job, or the matrix dimension, named true or false. The ci.yml sweep
    (#655) and effective_matrix_os (#653) collected keys with
    filter_map(as_str), which drops them: a job keyed true: carrying
    continue-on-error: true left the sweep green, and a true: matrix
    dimension got past the check that refuses any key but os and exclude.
    Review of #669 found the same shape in the release.yml permissions
    sweep, fixed there inline.

    The three now read keys through one helper, string_keys, which panics
    on a non-string key instead of skipping it. Four mutations, a true: and
    a false: job in ci.yml and a true: or false: dimension on the
    test and msrv matrices, each applied alone, fail the guard they aim
    at and all left the previous guards green; a fifth, the true: job in
    release.yml, stays red through the helper.

  • One added step could empty the release notes with every publish guard
    green
    (#665). #647
    pinned the steps of the publish path by name and left the rest of each
    publish job free-form. Review measured two ways through with every pin
    intact: gh release edit "$TAG" --notes "" added after the stable
    publish, and the notes file truncated by a step inserted between the
    resolver and the publish. A line appended to $GITHUB_ENV does the same,
    since it sets the environment of every later step, and the two run:
    steps nothing pinned, resolve tag and the rc duplicate check in
    pre-release.yml, could carry any of the three unseen. The job's own keys
    were a fourth way: a container: with an env: runs every run: step
    inside it, SHELLOPTS: noexec included, so the resolver writes no path
    and softprops publishes an rc with an empty body on a green run.

    Both publish jobs are now pinned whole: every key of the job but if:,
    which #647 already compares, then the step labels as an ordered list, so an
    added, removed or reordered step reads as a list in the failure, then every
    step by value. An action's @ref is left free, because Dependabot bumps
    github-actions here and pinning the ref would turn each of its pull
    requests red. What stays out of reach is what the actions and the scripts
    do inside: the rc duplicate check is pinned as a step, not the
    check-rc-changelog-dupes.sh it runs between the resolver and the publish,
    whose tests cover what it detects and not what else it does. So is the
    rest of the workflow: homebrew-tap-update and scoop-bucket-update ran
    after the publish with the workflow's write token and a GH_TOKEN in one
    of their steps, until #669 made them read-only. What stays out of reach
    there is the scope of the PATs they push with, which only CONTRIBUTING.md
    states, and the other workflows of this repository.

    Thirteen mutations, each applied alone, fail the new guard at the
    assertion they aim at. Twelve leave the previous guards green; the
    thirteenth, the duplicate check removed, was already caught by the test
    that requires it. Three controls stay green: the checkout and softprops
    refs bumped a major version, and the stable job's if: reflowed onto one
    line.

    assert_job_is_blocking also loses its step-level waiver parameter
    (#664). #659 removed the
    doctest step, the one step that held a waiver, and every caller has passed
    an empty list since, so the waiver checks ran against nothing and would
    have passed vacuously the day they were needed. A step-level if: is now
    refused outright, and a condition that becomes legitimate comes back with
    a test of its own.

  • The release publish guards were satisfied by something other than what
    they named
    (#647). Two
    guards on the path that publishes a release, and a third surface of it
    with no guard at all.

    The stable publish step holds two exclusive branches, gh release edit
    for a recovery rerun and gh release create for a fresh tag, and
    --notes-file was asserted once over both. Dropping it from create, the
    branch a real release takes, stayed green, and that release would have
    gone out with an empty body instead of changelogs/<version>.md: without
    --notes-file, gh has no prompt to fall back on in CI and sends no body
    at all. Notes missing from a release is what the guard was written
    against, after v0.6.0.
    --verify-tag, --draft=false, --prerelease=false and the .tar.gz /
    .zip uploads were asserted by nothing. The Linux package check ran
    contains("dist/*.deb") over the whole file, which dist/*.deb.sha256
    satisfies on its own, as does the build job's upload-artifact step,
    which publishes nothing. With the .deb and .rpm lines removed from
    release.yml, release_workflow_tests and linux_packaging_metadata_tests
    stayed green. And pre-release.yml's body_path, which is what keeps an
    rc's notes on changelogs/pre-releases/<version>.md, had no test at all.

    The steps are now read from the parsed workflow and pinned by value: the
    stable script as written, with its env and shell, and the whole with:
    of the pre-release step. By value rather than by flag, because a presence
    check loses to an addition: gh keeps the last value of a repeated flag, so
    --draft=false --draft=true publishes a draft with the substring still
    there, and softprops takes generate_release_notes and append_body,
    either of which changes the notes without touching body_path. As
    written, with nothing normalised: review found two ways a test that joins
    continuation lines itself disagrees with bash (\ with a trailing space
    ends a command, "$TAG"\ glued to --notes-file makes one word of them),
    each leaving the pin green over a broken release, so the test no longer
    models bash at all and a reformat of the step reformats the pin. The Linux
    check reads whole lines of that script rather than substrings of the file.

    Review also found the fix pinning a reference and not its target.
    --notes-file and body_path both read steps.changelog.outputs.path,
    and CHANGELOG_PATH="CHANGELOG.md" in the step that computes it left every
    test green, as did moving that step below the one reading its output: the
    v0.6.0 incident again, with every pinned key intact. The resolve changelog path step of both workflows is pinned the same way, and must come before
    the publish step. So are the jobs, since GitHub applies if: and
    continue-on-error: at the job level and the job wins (#646):
    continue-on-error: true on the release job turned a failed publish into a
    green run. The publish job must wait on build, and both must carry
    exactly the stable-tags condition, or none on the pre-release side, and no
    continue-on-error:. And the environment is pinned where it reaches the
    steps that resolve and publish the notes: SHELLOPTS: noexec has bash run
    nothing and exit 0, the path never written. Every pinned run: step has
    its env: pinned with its script, as a required argument of the check
    rather than a line each caller has to remember, both publish jobs carry no
    env:, and the workflow-level one is CARGO_TERM_COLOR alone. An action's
    inputs cannot be reached that way, since the runner writes INPUT_<NAME>
    for every declared input over whatever env: set. The other steps of the
    publish job, a step writing to $GITHUB_ENV among them, were left
    free-form here and are pinned under #665.

    Thirty mutations, each applied alone, all fail the guard they aim at.
    Twenty-nine of them leave the previous guards green; the other, a second step
    named publish release, failed them by accident, since the old text slice
    read whichever of the two came first.

  • CI no longer keeps a doctest step that ran zero doctests
    (#659). The move to
    nextest (#634) left cargo test --doc running beside it, because nextest
    cannot run doctests, and a guard held the step in place. The guard
    asserted the step was present and unconditioned, never that it ran
    anything, and it ran nothing: every fenced block in the doc comments is
    text, toml or go, and cargo test --doc -- --list answers
    0 tests. It could not fail.

    Writing doctests to give it a subject was the other way out, and it does
    not fit: the lib is the #![doc(hidden)] test seam, with no SemVer
    guarantee and a doc comment telling readers not to build on it, and
    behaviour is tested under tests/. A guard failing on a zero count has
    nowhere to live either, since #652 makes every cargo step one bare
    invocation. So the step is gone, with the reason written where it stood,
    and Cargo.toml declares doctest = false on the lib, which also stops a
    plain cargo test from compiling an empty doctest phase. The two halves
    are pinned together, because the key only covers a plain cargo test:
    an explicit cargo test --doc still runs doctests with it set. What no
    test sees is a Rust fence written under the declaration, which nothing
    compiles. Telling which fences rustdoc would run from the source is the
    scanner #634 threw away after finding it wrong ten ways.

  • Four of the eight CI jobs were guarded, and two of the unguarded ones are
    required checks on main

    (#655).
    assert_job_is_blocking had four callers for eight jobs, and the four were
    the ones #646's own audit happened to name. fmt, clippy and hook-smoke
    were not among them, and all three are contexts main requires: if: false,
    continue-on-error: true and || true on the command, applied to clippy
    at once, left all 27 tests across the two binaries that parse ci.yml green.
    Those are exactly the neutralisations #646, #652 and #653 exist to close,
    pointed at jobs nobody had pointed the guard at.

    The caller list is replaced by a sweep over jobs:, because a list is what
    produced this in the first place: #646 guarded what it had looked at, #652
    and #653 widened the helper without widening its callers, and a ninth job
    would arrive unguarded the same way. A sweep guards what it finds and says
    nothing about what stopped existing, so the jobs are also named: that
    enumeration is bounded, covering what ci.yml ships today while the sweep
    covers what it does not, which is the inverse of the caller list it replaces.
    A missing job is not always a blocked merge either, since only five of them
    are required contexts on main. doctor is the one exemption and it is
    pinned rather than waived. It is advisory by design, continue-on-error: true on its step and an if: restricting it to dev, and should it lose
    either property the exemption goes red instead of quietly covering a job it
    was not written for. hook-smoke had been written down as exempt too, on
    the reasoning that its shell gates are legitimately multi-line with pipes;
    running the guard against it before believing that said otherwise, since a
    run: block invoking no cargo is outside the command invariant's scope to
    begin with. It costs one iteration of the sweep and it is a required check,
    so it is guarded.

    Being blocking is not the whole property for these two. cargo fmt --all
    without --check rewrites the tree and exits 0, and cargo clippy without
    -D warnings exits 0 on every lint it finds. Both are one bare cargo
    invocation on one line under a built-in shell, so they satisfy every
    assertion the helper makes, and neither is --no-run or --dry-run, which
    is the bounded list it already carries. A flag, not a shell operator, is
    what separates enforcing from reporting here, the same class as cargo bench --no-run (#634) and a cargo audit without --deny warnings (#340).
    So --check, --all, -D warnings and --all-targets are asserted on the
    commands themselves, where CLAUDE.md already states them.

    For clippy the command is only half of it, and the other half wins. The lint
    level is the command and RUSTFLAGS, so env: RUSTFLAGS: "--cap-lints=allow" on the job exits it 0 on every lint in the tree with
    the denial sitting there untouched, on a required check, and takes half the
    MSRV guarantee (clippy::incompatible_msrv) with it. The flags reaching the
    job are pinned by value at the three levels env: exists, rather than
    screened for weakening spellings: --cap-lints, -A warnings,
    --force-warn and a -D warnings cancelled by an earlier flag are not a
    closed set, which is the denylist #652 already walked through.
    CARGO_ENCODED_RUSTFLAGS is refused outright, since cargo reads it instead
    of RUSTFLAGS and would leave the pin describing a variable nothing reads.

    The doctor exemption is pinned to the step that carries it, never asserted
    existentially over the job's steps. "Some step of doctor is
    continue-on-error" is satisfied by any of them, so moving the marker onto
    cargo build keeps such an assertion green while gwm doctor itself
    becomes able to fail the job, which is the exact drift the exemption claims
    to catch. "The job cannot turn the workflow red" is deliberately not the
    property asserted either, because it is not true and never was: doctor's
    checkout, toolchain install and cargo build all fail hard, and should.

    Both commands are pinned by value rather than screened flag by flag, for the
    same reason RUSTFLAGS is. A contains reads a line whose last flag wins:
    cargo clippy --all-targets --all-features -- -D warnings --cap-lints=allow
    keeps every substring a screen would look for, is one bare invocation,
    touches no env:, and exits 0 on every lint in the tree;
    --config=disable_all_formatting=true does the same to fmt. Refusing the
    neutralisation inside the variable while handing it over on the line beside
    it is not a guard. The doctor exemption is pinned by value too, since
    if: always() satisfies a presence check while losing the restriction the
    message names.

    Twenty-six mutations, each applied alone and each read back to confirm it
    fired the assertion it was aimed at and not an earlier one. Applying them
    together proves less than it looks, since the job-level if: is checked
    before continue-on-error: and both before the command, so a combined
    mutation panics on the first and never reaches the one it is meant to
    demonstrate. Two of the twenty-six mutated the test rather than the
    workflow, the lookup the step-level if: waivers went through at the time,
    plumbing that could break on its own; #659 and #664 have since removed the
    waivers, and those two mutations have nothing left to aim at. The set was
    replayed in full against the final code rather than accumulated across
    passes: a table quoting an assertion an earlier commit has since rewritten
    documents a guard nobody ships.

  • CI could still be silenced, below the job and above it
    (#652,
    #653). #646 pinned the job
    level. Three ways around it were left, each found by mutating the real
    workflow and each leaving the whole suite green.

    Below. A command can be made unable to fail without touching any of the
    keys #646 reads: cargo nextest run || true, a pipe, set +e, exit 0,
    if ! cargo audit …; then …; fi, a trailing &. Listing those spellings
    does not converge, because the shell is a programming language and the list
    is its grammar, so the guard states the property instead: a run: block
    that invokes cargo is exactly one bare cargo invocation, one line, made only
    of characters that carry no shell meaning. Every spelling above fails that,
    including the ones nobody has thought of yet, and all eight cargo commands
    in ci.yml pass it unchanged.

    Two things the shape alone does not reach are named separately. The shell:
    that runs the command, because shell: takes a whole command line rather
    than a keyword, so true {0} never runs the script and bash -n {0} only
    parses it: the built-ins that do run it are whitelisted, at step level and in
    defaults.run at job and workflow level. And the cargo flags that compile
    without executing, because cargo bench --no-run --benches -- --test
    satisfies the shape while being issue #634 verbatim.

    Steps that do not invoke cargo stay free-form, which is what keeps the read the declared MSRV step legal: it pipes grep into cut and uses exit 1
    to fail loudly, both correct. A step is recognised by a whitespace-delimited
    cargo token, so neither Cargo.toml nor a prefixed env VAR=x cargo …
    fools it.

    Above. strategy.matrix.exclude deletes matrix rows, which defeated the
    two guards whose entire subject is the matrix: they read
    strategy.matrix.os and never the exclude beside it, so
    test (windows-latest) could stop existing while
    ci_test_matrix_runs_on_windows_latest kept passing. Both now read the
    effective matrix, and runs-on: is pinned to it: a literal runner keeps
    test (windows-latest) in the checks list, satisfying the required contexts
    on main, while nothing ever compiles the
    [target."cfg(windows)".dependencies] block. The workflow's own on: block
    takes every job at once, so the events and branches it fires on are
    pinned, and paths:, paths-ignore: and types: are asserted absent: a
    path filter skips CI on a change it does not match, and types: has
    defaults, so replacing them runs no CI on an ordinary pull request. Branch
    membership alone was not enough either, since GitHub reads those entries as
    patterns and a later !dev cancels an earlier dev.

  • Three CI jobs could be switched off without a test going red
    (#646). GitHub Actions
    resolves if: and continue-on-error: at the job level as well as the
    step level, and the job wins. Every guard covering msrv, test and
    audit read step[...] and never the job containing it, so if: false
    on the test job left all 19 tests in release_workflow_tests green
    while cargo build, cargo nextest run and cargo test --doc had
    stopped running. audit had no test naming it at all, which is the job
    whose continue-on-error hid RUSTSEC-2025-0068 for nine months.

    The triplet already written for bench is now one helper,
    assert_job_is_blocking, shared by the two test binaries that parse
    ci.yml and applied to all four jobs. It also refuses an absent job and
    an empty steps:, because a deleted job parses to null and every
    assertion walks past null. No step may carry an if: either. The one
    legitimate one in the tree, the doctest step narrowed to the ubuntu matrix
    row, was pinned to its exact value until #659 removed the step, and #664
    removed the allowance with it rather than keep a path no caller drove. The
    audit guard adds --deny warnings and rejects a pipe on the command,
    since a pipeline reports its last element's exit status.

    A fifth way to switch a job off is covered as well, found while reviewing
    the first four: GitHub skips a job whose dependency was skipped, so
    needs: doctor on any of the four stops it on every event that does not
    target dev while its own if: and continue-on-error: stay clean.
    doctor sits one screen below audit in the same file and is narrowed
    exactly that way. The whole needs: closure is walked, since a job two
    hops from a conditional one is skipped the same way.

    Each guard was proved by mutating ci.yml and rerunning the two affected
    binaries, bench included, so the refactor cannot have traded coverage
    for a shared definition.

  • The warm-cache sidebar bench runs again, and a CI job now runs the
    benches
    (#634).
    benches/sidebar_cache_hit.rs drew one frame and asserted the sidebar
    cache had filled itself. That was the contract when it was written: the
    first frame ran git log and stored the rendered sections. #351 took git
    off the render path, so a frame warms nothing, and the bench has panicked
    on every run since, 1086 commits ago. cargo bench stops at the first
    failure, so it also masked the third bench, which was healthy all along.

    Nobody saw it because nothing ran the benches. Both halves are fixed
    here: the bench seeds the payload the way the event loop does, and a
    bench job runs all three on every push and pull request. The job passes
    criterion's --test, which runs each benchmark once and measures
    nothing, so it fails on a panic or a build break and never on timing
    noise from a shared runner. A perf gate that goes red on a slow runner is
    a perf gate somebody switches off.

    The old assertion does not come back. cache.is_some() is what let this
    rot: the renderer serves the cache only when its key matches the current
    selection and mode, so a payload under any other key renders the loading
    placeholder and the bench would still report a plausible number for
    drawing it. The timed frame is asserted to carry a real commit subject
    instead.

  • A config key with no value no longer crashes gwm
    (#633). A git config entry
    may carry no value at all (\tgwm-agent-pin with no =, git's
    implicit-boolean form). Config::remove_multivar runs its value regex
    against such an entry and segfaults inside libgit2, taking the
    .git/config.lock it had already opened with it, after which every config
    write in that repo fails, git's own included, until the lock is deleted by
    hand. gwm agents detach died that way, and so did the new
    gwm doctor --fix before this.

    The class is not "deleting", it is every libgit2 config call that takes
    a value regex
    : remove_multivar and equally set_multivar, whose
    pattern selects the value to replace. Stating it too narrowly is what let
    the crash survive two rounds of fixing, so the call sites are now
    enumerated by a test rather than by memory: adding one fails the suite
    until it is guarded. Reading is separate and simpler, ConfigEntry::value
    panicking on the same entry by documented contract. gwm agents reads past a
    valueless pin, detach clears what it can, and the one shape libgit2
    offers no safe primitive for (a key both multi-valued and partly
    valueless) is refused with a message naming the line to delete, rather
    than crashed on or half-deleted.

Changed

  • Two TUI views move to src/tui/views/, one module each for their
    state, their rendering and their keys

    (#635). state/ was
    already split one module per view, but the behaviour lived in app.rs
    (9765 lines) and the rendering in ui.rs (10366), so adding or changing
    a view meant editing three files, two of them enormous. The layering was
    horizontal while the variability is vertical. views/commits.rs and
    views/exec_picker.rs are the pilot.

    No behaviour change. The view move itself is invisible from outside the
    crate — views is private and every gwm::tui::* path resolves through
    the same re-exports — and it edits no test at all. The satellite
    repatriation that follows does touch five test files, but only to follow
    a field path: app.edit_failure became
    app.create_form.edit_failure. Undoing each rename with sed reproduces
    every one of those files byte for byte, so no value, assertion, case or
    test name moved. Two paths that did go away are
    gwm::tui::state::commits and gwm::tui::state::exec_picker, whose
    modules moved to views; everything they held is still re-exported at
    gwm::tui::*, and src/lib.rs declares the library an internal test
    seam with no SemVer guarantee (#342).

    The Commits overlay's key routing was a match sitting inside the run
    loop, which is the one shape a test cannot reach — the hole
    #613 named. It is now
    App::handle_commits_key, and four tests pin what it does, including
    the thing that distinguishes it from the Working Tree overlay: Commits
    resolves modal verbs only, with no global-toggle block in front, so a
    global commits key rebound onto j still scrolls here.

    App goes from 85 fields to 60. The issue's second scope point asks
    for the orphaned satellite fields to come home, on the two moved views
    and on any modal whose extraction stopped halfway, and all of them do:
    ExecPicker takes its captured config, commondir and container counter;
    CleanOverlay its config and countdown; DetailOverlay the worktree and
    forge link it was built for; CreateForm the two failure banners and the
    Edit origin; and ConfirmContext what the confirmation is actually
    confirming. Three modals had no state module at all, so state/help.rs,
    state/agents.rs and RichView in state/rich_view.rs are new.

    Three of those moves put a field next to a method that resets its
    neighbours, and each of the three would have been a silent behaviour
    change: ExecPicker::container_seq is the monotonic half of a
    containerised run's --name, so resetting it in open makes two runs on
    one worktree collide; DetailOverlay::target / link are pinned by the
    consumer before open, so clearing them there leaves the agents
    overlay attaching against nothing; and CreateForm::reset must leave the
    failure banners up, because one caller resets the form without clearing
    them. All three are now tests, each proven red by making the mistake.

    The churn the pilot was meant to measure did not move, and the reason
    is worth writing down.
    Of 2251 commits, 512 touch app.rs or ui.rs;
    21 of those touch either of the two moved surfaces, about 4%. Measured
    per view over the same 512, the ranking is roughly the inverse of the
    "most independent first" order the issue proposed: sidebar 61,
    create_form 41, header/footer 36, detail_overlay 33,
    working_tree 32, config_panel 27, against commits 13 and
    exec_picker 9.

    Those integers are upper bounds, not clean counts, and the method is
    why. They come from git log -G over identifiers named after each view,
    restricted to those two files, so a commit that only touched
    app.commits.scroll in the run loop counts for the Commits view even
    though that line is not moving. They over-count rather than under-count,
    which cuts the same direction as the conclusion. git log -L :funcname: would have been exact and works on ui.rs, but git's Rust
    funcname pattern does not match a method indented inside an impl, so
    it returns zero for every method in app.rs — a silent zero, not an
    error. The ordering is what carries the argument, and the ordering
    survives the imprecision.

    The views that are cheap to extract are cheap precisely because nothing
    changes them. So the mechanism works and the two pilot files are the
    right shape, but continuing down the independence list buys nothing:
    the next extraction worth doing is sidebar or create_form, and
    those are coupled, which is a different and larger piece of work than
    this one. Not folded in here.

  • CI runs the test suite under cargo-nextest
    (#634). cargo test runs
    110 test binaries in sequence, each with its own thread pool.
    cargo-nextest schedules all 3525 tests across one global pool and gives
    each test its own process. The tests themselves are untouched and all 3525
    pass under it.

    The speed case is weaker than the issue expected, which is worth writing
    down rather than leaving the next reader to assume otherwise. Measured
    over 11 cargo test runs on dev against 4 of the swapped job,
    normalised per test because the suite grew from 3273 to 3525 tests across
    that window, the median moves -8.9% on ubuntu, -6.5% on macos and +0.8%
    on windows. Real and in the same direction on the two Unix runners, but
    single-digit, and inside a far wider band: 29.2s to 42.2s of execution on
    ubuntu for the same command on the same suite. Every macos and windows
    sample falls inside the cargo test spread. The pooling gain the issue
    measured came off a local 8-core machine; a runner has fewer cores, so a
    per-binary pool already saturates them and process-spawn overhead eats
    what is left. The test step is compile-bound either way.

    What the swap does buy is process-per-test isolation. It surfaced a
    shared-fixture race in the test harness on its first run: the git shim
    directory is a fixed path, and the OnceLock guarding it only serialises
    the tests sharing one process, so process-per-test had several of them
    race an unlink-then-symlink. The link is staged and renamed into place
    now, which is atomic.

    Doctests are the one thing nextest gives up, and the crate has none, so
    nothing runs beside it: the lib declares doctest = false (#659). The
    MSRV job still compiles at the declared floor and runs no tests.

  • gwm list scans its worktrees in parallel
    (#633). Every row opens
    its own Repository, runs statuses() over that worktree and walks its
    branch age; nothing in that body touches the main repo, and it is where
    a listing's time goes. It now runs on up to available_parallelism()
    workers, work-stealing off an atomic cursor, the same shape
    gwm exec has used for its fan-out since #313.

    Row order is unchanged and guarded: repo.worktrees() is not sorted and
    the table renders what it returns, so results are written into per-index
    slots rather than collected as they complete. Everything read off the
    main repo stays on the calling thread, &Repository not being Sync.

    Measured with hyperfine, mean of 20, 8 worktrees, config size the only
    variable: 83 ms to 51 ms at 224 lines, 98 ms to 73 ms at 538, 184 ms to
    118 ms at 1434. Combined with a gwm doctor --fix on the same repo,
    184 ms becomes 100 ms. That last figure does not interpolate from the
    table: purging leaves 193 empty [branch "…"] headers behind, so a
    561-line config that has been swept is not the same object as a 538-line
    one that never grew.

    This also speeds up gwm statusline, the daemon and every TUI refresh,
    which share the same listing path.

  • The Settings panel gets a value column, named sections and tab glyphs
    (#623). The panel carries the
    most content of any modal and showed it the most plainly: values were a span
    glued after a fixed 24-cell label pad, so a tab of sixteen rows had sixteen
    different value positions, and the one 26-character label overflowed the pad
    and pushed its value two cells right of every other. The TUI tab was a
    single undivided run mixing layout, sidebar, editing, open, multiplexer,
    browser and refresh knobs, and the five tabs were bare words.

    Every tab now reads as two columns: what a row is on the left, what it is set
    to on the right, right-aligned against the panel's own edge so its width is
    used rather than left empty beside a content-sized block. On the editable
    tabs the shape carries the kind: a choice reads ‹ value ›, a boolean reads
    [✓] / [ ], a typed value reads plain, and an optional text field nobody
    has set reads (unset) instead of leaving the column blank. Keys puts its
    binding there and All its resolved value, dropping the = that used to
    join a key to it. On a panel too narrow for both columns the gap falls back
    to a minimum and the row pans sideways, as it always did.

    A section rule mutes its separator and not its name: the rule is chrome and
    recedes, while the name keeps the theme role the bare heading wore before it
    was given a rule to sit in.

    The bootstrap report's Logs becomes a subtitle. It was a nested section
    with its own chrome, which under [tui] layout = "compact" paints the same
    full-width accent band the modal's own title rides, so the two stacked and
    the second read as a second title.

    The Keybindings overlay (?) gets the same treatment, which is the pass
    #623 deferred until the Settings panel had proved it out. Its description
    leads and its chord is the right-hand column, the order the Settings Keys
    tab already used for the same data; its Blank / Section / Blank headings
    become one blank and one labelled rule, giving back a row per section on a
    body with a dozen of them, with the single blank kept so a section does not
    read as a continuation of the one above it; and it is a little wider, because
    60% of a 100-column terminal is 64 cells for a body that now wants about 73.

    Forty of its descriptions were shortened to fit beside their chords, and one
    fixed chord string with them: Left/Right/Up/Down was eighteen cells, and
    being the widest in the body it set the key column for every row. At 80
    columns nothing is truncated now, pinned by a test, so a new binding with a
    long description fails rather than quietly shortening every other row's.

    Sections are labelled rules across every tab: the TUI tab gains seven
    (Appearance, Sidebar, Editing, Open, Multiplexer, Browser, Timing), which
    reorders its fields into those runs, and the [global] / [table] headings
    the Keys and All tabs already had become the same rule. A blank sits
    ahead of every break and under the tab strip, so a section does not read as a
    continuation of the one above it and the first rule opens the body rather
    than hanging off the chrome. Each tab is led by a codicon glyph.

    That spacing costs rows, so #569's
    height bounds move from (11, 32) to (12, 40): the TUI tab is 29 body
    rows now, and at the old ceiling a form the user opened deliberately would
    have scrolled by seven. modal_height still takes term_height - 4 after
    the ceiling, so a short terminal is unaffected and scrolls as it did.

    ← / → now adjust the selected choice, one value back or forward. The
    issue described them as already doing this; they did not, since
    ConfigScrollLeft / ConfigScrollRight were gated to the All tab's
    horizontal pan and were dead everywhere else. The ‹ › markers are what
    advertises them. No binding changed.

    The footer gains a j/k move verb, and ←/→ adjust replaces Space cycle
    on a cyclable field rather than joining it: modal_hint_line centres its
    spans, so naming one operation twice overflowed the line and clipped it at
    both ends, costing the leading hint outright and leaving Esc close as
    Esc c. Five verbs still need nine more columns than the four that came
    before, so on a panel too narrow for them move drops out instead: the
    footer keeps fitting every terminal the old one fitted, rather than trading
    a narrow terminal's Esc close for a movement hint every TUI shares.

  • The doc captures regenerate as one step, in the order the traps require
    (#631). Regenerating the set
    was a four-step sequence held together by a maintainer's notes: bump,
    cargo install, generate.sh for 22 of the 24 tapes, then demo.tape and
    github-linking.tape by hand. Every step had an ordering constraint that was
    invisible until it bit, and none of it was visible to CI: the files exist,
    the widths pass, vhs exits 0.

    docs/_capture/generate.sh now covers the whole set and owns the order.
    It builds the gwm it drives from the tree being captured and puts it first
    on PATH, instead of documenting whichever build a shell happens to resolve;
    v1.10.0 came within a commit of publishing captures 175 commits stale that
    way, correctly sized and green. github-linking.tape runs first, before
    anything else writes under docs/, and only when the repo's main checkout is
    clean and its branch has an open PR, so the release commit cannot leak into
    the Working Tree pane it photographs. The main checkout, not the current
    directory: the pane follows the selected row, which is row 1 wherever the
    tape ran, and the tape now takes that path from the script rather than
    hardcoding one. demo.tape runs last, because it is the one tape that
    changes the fixture for the others. Both were previously outside the loop and
    unreported, so a run finished on a tick over two assets it had left stale.

  • The Working Tree listing reads as columns
    (#622). The M / A / D
    / ? status letter used to lead the file name, so on a 27-file change set
    it sat at a different offset on every row and the eye had to re-find it. It
    is now a right-aligned column of its own, in both the sidebar pane and the
    full-size overlay, and it keeps the per-category colour it always had.

    The column is pinned to the right edge of its surface at every width, and
    the letter is priced far below what the +N -M column costs: a pane or an
    overlay too narrow to seat the counts still seats the letter. Before this
    the letter was an inline badge two cells wide that no width ever dropped,
    so charging it the counts' floor would have lost a capability rather than
    yielded a column.

    The +N -M line counts from #592 now ride the sidebar pane too, inside
    the letters. That pane had deliberately stopped at the rows to save the
    git diff --numstat they need, since it re-reads on every selection
    change; it pays for that read now, on the sidebar worker rather than the
    render path.

    In both surfaces the letter shares the right end of the row with those counts,
    and the two yield in a fixed order: the letter is carved out first, so a
    narrowing terminal drops the counts and never the letter. +N -M says how
    much a file changed, the letter says what happened to it, and a row that no
    longer says what it is has lost its subject rather than a detail.

    The overlay title carries the changed-file count, Working Tree (27). It
    comes from the per-category counts rather than the row count, so it agrees
    with the footer: the rows also hold directories, the … N more overflow
    notice and the ✓ clean sentinel, none of which is a changed file. It is
    withheld while the listing is still being read, since (0) there would be a
    claim rather than a count.

    Directory rows lead with a ▾ disclosure caret instead of the folder glyph
    they carried since #300. A folder glyph and a file glyph are two icons of
    the same weight, which left indentation alone to separate the two levels;
    a caret is a different shape. File rows keep their per-extension glyph.

    The collapsed leading path the issue also asks for was already there:
    build_tree has folded single-child directory chains into one a/b/c row
    since #300.

Docs

  • CONTRIBUTING.md § Releases carries the capture step and the three ordering
    constraints behind it, and docs/_capture/README.md no longer describes two
    tapes as living outside the script
    (#631).

  • The five ::: callouts under docs/ are GFM alerts
    (#641). Container syntax is
    markdown-it / Nuxt Content, and this tree is read by neither: GitHub emitted
    the directive as literal text in a paragraph, and Starlight wants
    :::caution[title] with the colons glued to one of four names it knows,
    which ::: warning is not. So the security callout on the GitLab page, the
    one explaining that a bare forge key authorises nothing, was raw
    punctuation in both places. The one that carried a title keeps it as a bold
    first line inside the quote, since a GFM alert has no title slot.
    docs/fr/5.integrations/1.github-linking.md gains the multi-forge callout
    its English half already had. tests/docs_callout_tests.rs rejects the whole
    ::: prefix rather than the spaced form the tree happened to carry:
    :::note is valid Starlight and would render on the site while staying
    literal on GitHub, which is the same defect seen from the other side. Astro
    does not implement GFM alerts natively, so the matching half, folding them
    back into Starlight asides, lives in
    kbrdn1/kbrdn-docs#81.