Repository navigation
[1.11.0] - 2026-10-26
Security
-
cargo auditfailed ondevitself: the YAML parser under the crate is
unmaintained (#688).
RUSTSEC-2024-0449marksunsafe-libyamlunmaintained: 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 throughserde_yaml_ng 0.10.0, a direct dependency, and the advisory
entered the database after this repo's last greencargo 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-saphyrfor the one typed parse insrc/, andsaphyrfor the
dynamic tree the workflow guards read.unsafe-libyamlis gone from the
graph entirely rather than accepted through anignoreinaudit.toml,
whichci.ymlallows with a rationale (#340) and which #688 offered as the
other outcome.The other candidates, and why each was not taken.
serde_norwaydepends 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_ymlcarriesRUSTSEC-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 carriesRUSTSEC-2026-0333, but
that advisory ispatched = [">= 0.0.53"]and the current release is 0.0.58,
so a currentnoyalibis 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, andserde_yml, from the same author, is the
archived unsound crate above.serde-saphyris 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 intoserde_json::Valuethroughserde-saphyr
would have been a near-mechanical rename of every read the swap touches,
the bulk of them inrelease_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, sostring_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 keytrue:as the string"true",
which leavesstring_keysunable to fire at all and turns the guard from
#673 vacuous. Both measured before the route was abandoned.tests/workflow_reader_tests.rspins 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 everymust 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 onis_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
onegwmreads none of (a secondname:), no longer parses, where
serde_yaml_nglet it through. Two paths parse a form and they fail it
differently.gwm newrefuses it, inserde-saphyr's wording, as it does
any form it cannot parse.gwm create --issueand the TUI's prefill
from an issue parse the whole form to take itstitle:, 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-typesrather thanadd-config-types).A YAML merge key now contributes its fields.
serde_yaml_ngdid 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 astype;serde-saphyrresolves merge keys, a YAML 1.1
type.gwm newrenders the merged fields into the issue it opens.
gwm create --issueand the TUI's prefill move the other way: a form
rejected for atypeits merge carried used to contribute no prefix, and
now contributes itstitle:, so unless the type's config sets
title_prefixthe 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.
MappingOwnedis aLinkedHashMapcomparing element by element, where the
oldMappingwrapped anIndexMapand compared order-insensitively, so
swapping two lines of a pinnedenv: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) andrelease.yml(#669) each had a test reading their grant
by value.docs-sync.ymlandpre-release.ymlwere 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-allon 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.ymlmust grant{}, the strongest
statement in this repo and the one an edit widens most easily, since it is
triggered by a push tomainand authenticates its one API call with a PAT
that is not the workflow token.pre-release.ymlmust grant
contents: writefor its publish, and is held to the same split
release.ymlhas: onlybuildandreleasemay inherit that grant, any
other job must carrycontents: 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 readsNone
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-allis the other half of the same cause and not the same trap: it is
a string, readsSome, and defeats a presence check instead, which is how
it passed the existing sweep (#669). Four mutations, each applied alone:
both grants flipped towrite-all, a scope written ondocs-sync.yml's
only job, and a third job added topre-release.ymlafter the publish with
no grant of its own. -
ci.ymldeclared nopermissions:, so its nine jobs took whatever the
repository setting said
(#677).
default_workflow_permissionsreadsreadtoday, 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.ymlcarriespermissions: {},release.ymland
pre-release.ymlcarrycontents: write, and the two jobs that run after
release.yml's publish carrycontents: 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 --lockedbuilding from
source, and five third-party actions, one of which copies the token to
disk:cachix/install-nix-action@v31falls back toGITHUB_TOKENwith no
github_access_tokeninput and writesaccess-tokens = github.com=<token>
into/etc/nix/nix.conf(install-nix.shat that tag, lines 50-52,
installed at line 93), which puts back for the rest of the job what
persist-credentials: falsekeeps out of the git config (#433).ci.ymlnow grantscontents: readat 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/checkoutthrough itstoken:
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 nopermissions:, 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
wordstokenandsecret, in any casing. Review walked the first version of
that search, three keys insidesteps:, and foundrun:the only one any
real input exercised, awith:dropped from the list staying green, and
every job-level surface unread,container:,services:and a reusable
call'ssecrets: inheritamong them. Refusing the words rather than their
spellings closesgithub['token']andtoJSON(secrets)too, which are the
next two forms and not the last. Nine synthetic shapes are asserted to be
seen, sinceci.ymlcarries 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 unauthenticatednixpkgs
fetch, rate-limited per runner IP. -
The jobs that run after the release publish no longer hold a write
token (#669).
release.ymlgrantscontents: writeat the workflow level for the
publish, and every job inherited it.homebrew-tap-updateand
scoop-bucket-updaterun after the publish with that token and a
GH_TOKENin 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, whichpermissions:
does not govern. Both now carrypermissions: contents: read.A test sweeps the jobs of
release.yml: each carries exactlycontents: read, compared by value, unless it isbuildorrelease, 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.buildkeeps 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: readis enough at run time.
Both jobs arecontinue-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
repeatedREPOcell. Each repo is now an accordion headed by its main
worktree row:Leftfolds the cursor's group down to that header,Right
unfolds it (collapse_group/expand_groupunder[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 themutedrole 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 letddelete 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
REPOcolumn 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 doctorreports the config a deleted branch left behind, and
--fixdrops it
(#633).gwm create
writes about sevenbranch.<name>.gwm-*keys per branch and nothing
removes them when the branch goes away, so.git/configonly grows. That
is not cosmetic: libgit2 reparses the file on everyRepository::open,
andstatuses()andbranch.upstream()each take a config snapshot that
duplicates every entry, sogwm listpays 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
--fixcan 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--fixthat has already failed on them and warning again
every run.--fixis opt-in because it edits.git/config; a plaingwm 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
byinclude.pathlives 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:doctorexists 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, not1434 - 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 noEvent::Mousearm 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 whatTaband the digits set.The header carries
▤and⚙left of the pinned version chip, opening the
Command Logs and Settings panels the way3and4do. 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 fromAppstate 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.Mreleases the mouse outright, handing the terminal's text selection back
until it is pressed again — and the release survives a trip through lazygit,
anexecrun 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 modalsI,a,W
andcopen), 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 —Escand the modal keymap's
activate— so a rebind reaches the mouse and no modal grows a second copy
of its own teardown.[tui] mousedecides whether any of this is on, defaulting totrueand
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 — soMswitches
between the two for a session,[tui] mouse = falsestarts every session on
the other side of it, and the header carries amouse off · Mchip 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'smouse-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 agwm X.Y.Zchip, 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 asksgwm --versionthrough vhs, from the same shell and the
samePATHevery other tape resolvesgwmthrough,generate.shaborts the
run when the answer is not the version inCargo.toml, and commits it to
docs/_capture/captured-version.txtonce the run completes. The new
docs_assets_tests::captures_were_generated_at_the_manifest_versioncompares
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.rsdrives 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 theassert!(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 to9999999and
taking the counter out of the panic:env_guard_invariant_testsaudited
said 9 against 11,em_dash_guard_testsscannedsaid 4000 against 5610 and
visited30 against 75,release_workflow_testsauditedsaid 8 against
11, and the twonaming_teststoken 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_guardone 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 theset_var/remove_varaudit
entirely with the count still above the floor. The other
release_workflow_testsfloors 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 >= 4andswept >= 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:
parsedafterdescription()returnsSome,resolvedper 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.scannedis
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_linesand
docs_assets_tests::image_targetsboth 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. Ondocs/4.configuration/1.gwm-toml.mdthat 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.mdcarried 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 whetherfromTOMLand./Cargo.tomlappeared
anywhere inflake.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
versionor ends inpackage.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.versionof the./Cargo.tomlread, 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 offlake.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 aninherit, 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 newci.ymljob,flake version (nix eval), installs nix and compares thenameandversionof thegwm
package with thepackage.versionofCargo.toml, both read by nix. Five mutations offlake.nix, each applied alone to a
copy and run through the job's own script:inherit (pin) version;with
pinfrombuiltins.fromJSON,inherit (pins) cargoToml;,
${"version"} = "0.3.0-rc.3";,buildRustPackage (pin // { … }), and a
name = "gwm-0.3.0-rc.3";next topname. Against1.10.0in
Cargo.toml, nix reports the package asgwm-0.3.0-rc.3for all five,
itsversionstill1.10.0for 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 thea = src.a;it is
sugar for, so bothinheritforms fail it, and
inherit (cargoToml.package) version;with noversion =binding, the
idiomatic spelling it used to refuse, passes. It stays because it runs
wherevercargo testdoes, 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 onmainuntil the branch protection lists it. -
The flake advertised a package for a platform its nixpkgs no longer has
(#675).
flake-utils.lib.eachDefaultSystemexposesx86_64-darwin, and the pinned
nixpkgs is 26.11, which dropped that platform and throws on import:
nix eval .#packages.x86_64-darwin.gwm.nameansweredNixpkgs 26.11 has dropped support for x86_64-darwin, so an Intel Mac runningnix profile install github:kbrdn1/gwm-cligot nixpkgs' refusal in place of gwm, while
the other three systems answeredgwm-1.10.0. The flake now names the
systems it serves,x86_64-linux,aarch64-linuxandaarch64-darwin.
Intel macOS keeps the prebuilt archive,cargo binstallandcargo install, which the install docs now say. Followingnixpkgs-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
flakejob now evaluates every systempackagesexposes rather than
x86_64-linuxalone, 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 offlake.nixmeasured
through the job's own script: witheachDefaultSystemrestored 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
emptiedpackagesfails on the missing attribute, and thename =
override of #672 still fails.flake_lists_the_systems_it_servespins the list as text too, because
eachDefaultSystemis 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 theeachSystemlist 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
readstrue:andfalse:as booleans, where GitHub Actions reads the
job, or the matrix dimension, namedtrueorfalse. Theci.ymlsweep
(#655) andeffective_matrix_os(#653) collected keys with
filter_map(as_str), which drops them: a job keyedtrue:carrying
continue-on-error: trueleft the sweep green, and atrue:matrix
dimension got past the check that refuses any key butosandexclude.
Review of #669 found the same shape in therelease.ymlpermissions
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, atrue:and
afalse:job inci.ymland atrue:orfalse:dimension on the
testandmsrvmatrices, each applied alone, fail the guard they aim
at and all left the previous guards green; a fifth, thetrue: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_ENVdoes the same,
since it sets the environment of every later step, and the tworun:
steps nothing pinned,resolve tagand the rc duplicate check in
pre-release.yml, could carry any of the three unseen. The job's own keys
were a fourth way: acontainer:with anenv:runs everyrun:step
inside it,SHELLOPTS: noexecincluded, 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@refis left free, because Dependabot bumps
github-actionshere 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.shit 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-updateandscoop-bucket-updateran
after the publish with the workflow's write token and aGH_TOKENin 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'sif:reflowed onto one
line.assert_job_is_blockingalso 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-levelif: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 andgh release createfor a fresh tag, and
--notes-filewas asserted once over both. Dropping it fromcreate, the
branch a real release takes, stayed green, and that release would have
gone out with an empty body instead ofchangelogs/<version>.md: without
--notes-file,ghhas 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=falseand the.tar.gz/
.zipuploads were asserted by nothing. The Linux package check ran
contains("dist/*.deb")over the whole file, whichdist/*.deb.sha256
satisfies on its own, as does the build job'supload-artifactstep,
which publishes nothing. With the.deband.rpmlines removed from
release.yml,release_workflow_testsandlinux_packaging_metadata_tests
stayed green. Andpre-release.yml'sbody_path, which is what keeps an
rc's notes onchangelogs/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 itsenvandshell, and the wholewith:
of the pre-release step. By value rather than by flag, because a presence
check loses to an addition:ghkeeps the last value of a repeated flag, so
--draft=false --draft=truepublishes a draft with the substring still
there, and softprops takesgenerate_release_notesandappend_body,
either of which changes the notes without touchingbody_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-filemakes 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-fileandbody_pathboth readsteps.changelog.outputs.path,
andCHANGELOG_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. Theresolve changelog pathstep of both workflows is pinned the same way, and must come before
the publish step. So are the jobs, since GitHub appliesif:and
continue-on-error:at the job level and the job wins (#646):
continue-on-error: trueon the release job turned a failed publish into a
green run. The publish job must wait onbuild, 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: noexechas bash run
nothing and exit 0, the path never written. Every pinnedrun:step has
itsenv: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 isCARGO_TERM_COLORalone. An action's
inputs cannot be reached that way, since the runner writesINPUT_<NAME>
for every declared input over whateverenv:set. The other steps of the
publish job, a step writing to$GITHUB_ENVamong 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
namedpublish 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) leftcargo test --docrunning 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,tomlorgo, andcargo test --doc -- --listanswers
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 undertests/. 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,
andCargo.tomldeclaresdoctest = falseon the lib, which also stops a
plaincargo testfrom compiling an empty doctest phase. The two halves
are pinned together, because the key only covers a plaincargo test:
an explicitcargo test --docstill 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 onmain
(#655).
assert_job_is_blockinghad four callers for eight jobs, and the four were
the ones #646's own audit happened to name.fmt,clippyandhook-smoke
were not among them, and all three are contextsmainrequires:if: false,
continue-on-error: trueand|| trueon the command, applied toclippy
at once, left all 27 tests across the two binaries that parseci.ymlgreen.
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 whatci.ymlships 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 onmain.doctoris the one exemption and it is
pinned rather than waived. It is advisory by design,continue-on-error: trueon its step and anif:restricting it todev, and should it lose
either property the exemption goes red instead of quietly covering a job it
was not written for.hook-smokehad 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--checkrewrites the tree and exits 0, andcargo clippywithout
-D warningsexits 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-runor--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 ascargo bench --no-run(#634) and acargo auditwithout--deny warnings(#340).
So--check,--all,-D warningsand--all-targetsare asserted on the
commands themselves, whereCLAUDE.mdalready states them.For clippy the command is only half of it, and the other half wins. The lint
level is the command andRUSTFLAGS, soenv: 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 levelsenv:exists, rather than
screened for weakening spellings:--cap-lints,-A warnings,
--force-warnand a-D warningscancelled by an earlier flag are not a
closed set, which is the denylist #652 already walked through.
CARGO_ENCODED_RUSTFLAGSis refused outright, since cargo reads it instead
ofRUSTFLAGSand would leave the pin describing a variable nothing reads.The
doctorexemption is pinned to the step that carries it, never asserted
existentially over the job's steps. "Some step ofdoctoris
continue-on-error" is satisfied by any of them, so moving the marker onto
cargo buildkeeps such an assertion green whilegwm doctoritself
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 andcargo buildall fail hard, and should.Both commands are pinned by value rather than screened flag by flag, for the
same reasonRUSTFLAGSis. Acontainsreads 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 noenv:, and exits 0 on every lint in the tree;
--config=disable_all_formatting=truedoes the same tofmt. Refusing the
neutralisation inside the variable while handing it over on the line beside
it is not a guard. Thedoctorexemption 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-levelif:is checked
beforecontinue-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-levelif: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: arun: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
inci.ymlpass it unchanged.Two things the shape alone does not reach are named separately. The
shell:
that runs the command, becauseshell:takes a whole command line rather
than a keyword, sotrue {0}never runs the script andbash -n {0}only
parses it: the built-ins that do run it are whitelisted, at step level and in
defaults.runat job and workflow level. And the cargo flags that compile
without executing, becausecargo 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 MSRVstep legal: it pipesgrepintocutand usesexit 1
to fail loudly, both correct. A step is recognised by a whitespace-delimited
cargotoken, so neitherCargo.tomlnor a prefixedenv VAR=x cargo …
fools it.Above.
strategy.matrix.excludedeletes matrix rows, which defeated the
two guards whose entire subject is the matrix: they read
strategy.matrix.osand never theexcludebeside it, so
test (windows-latest)could stop existing while
ci_test_matrix_runs_on_windows_latestkept passing. Both now read the
effective matrix, andruns-on:is pinned to it: a literal runner keeps
test (windows-latest)in the checks list, satisfying the required contexts
onmain, while nothing ever compiles the
[target."cfg(windows)".dependencies]block. The workflow's ownon:block
takes every job at once, so the events and branches it fires on are
pinned, andpaths:,paths-ignore:andtypes:are asserted absent: a
path filter skips CI on a change it does not match, andtypes: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!devcancels an earlierdev. -
Three CI jobs could be switched off without a test going red
(#646). GitHub Actions
resolvesif:andcontinue-on-error:at the job level as well as the
step level, and the job wins. Every guard coveringmsrv,testand
auditreadstep[...]and never the job containing it, soif: false
on thetestjob left all 19 tests inrelease_workflow_testsgreen
whilecargo build,cargo nextest runandcargo test --dochad
stopped running.audithad no test naming it at all, which is the job
whosecontinue-on-errorhid RUSTSEC-2025-0068 for nine months.The triplet already written for
benchis now one helper,
assert_job_is_blocking, shared by the two test binaries that parse
ci.ymland applied to all four jobs. It also refuses an absent job and
an emptysteps:, because a deleted job parses to null and every
assertion walks past null. No step may carry anif: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
auditguard adds--deny warningsand 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: doctoron any of the four stops it on every event that does not
targetdevwhile its ownif:andcontinue-on-error:stay clean.
doctorsits one screen belowauditin the same file and is narrowed
exactly that way. The wholeneeds:closure is walked, since a job two
hops from a conditional one is skipped the same way.Each guard was proved by mutating
ci.ymland rerunning the two affected
binaries,benchincluded, 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.rsdrew one frame and asserted the sidebar
cache had filled itself. That was the contract when it was written: the
first frame rangit logand 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 benchstops 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
benchjob 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-pinwith no=, git's
implicit-boolean form).Config::remove_multivarruns its value regex
against such an entry and segfaults inside libgit2, taking the
.git/config.lockit 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 detachdied that way, and so did the new
gwm doctor --fixbefore this.The class is not "deleting", it is every libgit2 config call that takes
a value regex:remove_multivarand equallyset_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 agentsreads past a
valueless pin,detachclears 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 inapp.rs
(9765 lines) and the rendering inui.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.rsand
views/exec_picker.rsare the pilot.No behaviour change. The view move itself is invisible from outside the
crate —viewsis private and everygwm::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_failurebecame
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::commitsandgwm::tui::state::exec_picker, whose
modules moved toviews; everything they held is still re-exported at
gwm::tui::*, andsrc/lib.rsdeclares the library an internal test
seam with no SemVer guarantee (#342).The Commits overlay's key routing was a
matchsitting 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
globalcommitskey rebound ontojstill scrolls here.Appgoes 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:
ExecPickertakes its captured config, commondir and container counter;
CleanOverlayits config and countdown;DetailOverlaythe worktree and
forge link it was built for;CreateFormthe two failure banners and the
Edit origin; andConfirmContextwhat the confirmation is actually
confirming. Three modals had no state module at all, sostate/help.rs,
state/agents.rsandRichViewinstate/rich_view.rsare 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_seqis the monotonic half of a
containerised run's--name, so resetting it inopenmakes two runs on
one worktree collide;DetailOverlay::target/linkare pinned by the
consumer beforeopen, so clearing them there leaves the agents
overlay attaching against nothing; andCreateForm::resetmust 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 touchapp.rsorui.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:sidebar61,
create_form41, header/footer 36,detail_overlay33,
working_tree32,config_panel27, againstcommits13 and
exec_picker9.Those integers are upper bounds, not clean counts, and the method is
why. They come fromgit log -Gover identifiers named after each view,
restricted to those two files, so a commit that only touched
app.commits.scrollin 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 onui.rs, but git's Rust
funcname pattern does not match a method indented inside animpl, so
it returns zero for every method inapp.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 issidebarorcreate_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 testruns
110 test binaries in sequence, each with its own thread pool.
cargo-nextestschedules 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 11cargo testruns ondevagainst 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 thecargo testspread. 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 theOnceLockguarding 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 declaresdoctest = false(#659). The
MSRV job still compiles at the declared floor and runs no tests. -
gwm listscans its worktrees in parallel
(#633). Every row opens
its ownRepository, runsstatuses()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 toavailable_parallelism()
workers, work-stealing off an atomic cursor, the same shape
gwm exechas 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,&Repositorynot beingSync.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 agwm doctor --fixon 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. TheTUItab 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.Keysputs its
binding there andAllits 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
Logsbecomes 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; itsBlank / Section / Blankheadings
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/Downwas 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
TUItab gains seven
(Appearance, Sidebar, Editing, Open, Multiplexer, Browser, Timing), which
reorders its fields into those runs, and the[global]/[table]headings
theKeysandAlltabs 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): theTUItab is 29 body
rows now, and at the old ceiling a form the user opened deliberately would
have scrolled by seven.modal_heightstill takesterm_height - 4after
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/ConfigScrollRightwere gated to theAlltab's
horizontal pan and were dead everywhere else. The‹ ›markers are what
advertises them. No binding changed.The footer gains a
j/k moveverb, and←/→ adjustreplacesSpace cycle
on a cyclable field rather than joining it:modal_hint_linecentres its
spans, so naming one operation twice overflowed the line and clipped it at
both ends, costing the leading hint outright and leavingEsc closeas
Esc c. Five verbs still need nine more columns than the four that came
before, so on a panel too narrow for themmovedrops out instead: the
footer keeps fitting every terminal the old one fitted, rather than trading
a narrow terminal'sEsc closefor 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.shfor 22 of the 24 tapes, thendemo.tapeand
github-linking.tapeby 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,vhsexits 0.docs/_capture/generate.shnow covers the whole set and owns the order.
It builds thegwmit drives from the tree being captured and puts it first
onPATH, 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.taperuns first, before
anything else writes underdocs/, 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.taperuns 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). TheM/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 -Mcolumn 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 -Mline 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 --numstatthey 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 -Msays 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 moreoverflow
notice and the✓ cleansentinel, 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_treehas folded single-child directory chains into onea/b/crow
since #300.
Docs
-
CONTRIBUTING.md § Releases carries the capture step and the three ordering
constraints behind it, anddocs/_capture/README.mdno longer describes two
tapes as living outside the script
(#631). -
The five
:::callouts underdocs/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::: warningis not. So the security callout on the GitLab page, the
one explaining that a bareforgekey 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.mdgains the multi-forge callout
its English half already had.tests/docs_callout_tests.rsrejects the whole
:::prefix rather than the spaced form the tree happened to carry:
:::noteis 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.