Skip to content
gwmgwmgwmv1.10.0

v1.6.0

Security release. A branch name could inject a command into a lifecycle hook (GHSA-fffq-vg6f-gxqm, high). Placeholder values were expanded into sh -c unescaped, and git permits ;, |, &, $, backticks and redirections in a ref name, so a branch pushed by somebody else could run arbitrary commands as anyone who had (legitimately) trusted their own repo’s hooks. No trust prompt was involved: the gate asks about the repo’s hooks and never covered the branch name flowing into them. Every version up to and including 1.5.0 is affected. Upgrade.

The feature line is naming flexibility. gwm create --name spike-redis drops the <type> <issue> <desc> requirement (#416), and the TUI create and rename forms now present the fields the repo’s own patterns actually ask for, in the order those patterns write them, rather than the canonical triple (#418). Both forms move between the two shapes with the same verb, in both directions.

Verifying that sequence end to end is what produced most of the Fixed section below, including two further places where data from a repo reached a shell or a terminal without being treated as data: .gwm.toml values echoed with their control bytes intact (#473) and placeholder expansion that re-scanned what it had just written (#494).

The MSRV is now declared honestly at 1.95 (#491), where Cargo.toml had claimed 1.86 for a whole release line while the locked graph needed more, and a CI job holds it on all three runners from now on.

  • The TUI rename form (c) now handles free-form worktrees, in both directions. A worktree created with gwm create --name spike-redis could not be renamed from the TUI at all: worktree_spec starts by parsing the branch, a name chosen on purpose does not parse, and the form refused outright. That left the two ordinary cases impossible, renaming a spike that outlived its name and promoting one into the convention once it acquires an issue number, and both are exactly what the form exists for.

    Such a branch now opens the form in free-form mode with its current name prefilled, and Ctrl-T moves between the two shapes in either direction, the same verb and the same two modes the create form has had since #416. All four conversions work: free-form to free-form and structured to free-form write the name verbatim as the branch and flatten / to - for the directory; free-form to structured applies branch_pattern and path_pattern to the triple; structured to structured is unchanged.

    The four cells are two code paths, not four. WorktreeName already knew how each shape becomes a branch and a directory, so the rename target is composed through it exactly the way the create target is, and the live preview reads the same value the submit will write. That last point is not incidental: the preview and the submit drifting is precisely how both forms came to display a branch they were not going to create, and free-form is the same trap one mode over, since there no pattern is expanded at all.

    Toggling seeds only what is still empty, so a round trip never overwrites typed input. Leaving structured seeds the name with the current branch verbatim. Leaving free-form seeds the description with kebab of the name, capped at the length the field enforces when typed, and leaves the issue empty; kebab’s output is by construction either a valid description or empty, so the seed can never dead-end the form on a value it would then refuse. The type is neither seeded nor blanked, it stays on whatever the selector shows, which is the create form’s own contract and is spelled out by the preview.

    Names are validated with the same WorktreeName::freeform rules as gwm create --name, so a name one form refuses the other refuses too. Two guards come along with the change. A worktree.base written with {type} / {issue} / {desc} is refused for a free-form target rather than expanded to a literal, since a free-form name has no value for them. And the main worktree is now refused explicitly: its branch is normally unparseable (main, dev), so the old refusal was turning this form away from it by accident, and free-form mode parses nothing, so that side effect had to be replaced by the same stated guard enter_confirm_delete uses.

    This inverts a decision from #416, which deliberately kept the rename modal single-mode; the test that pinned it is rewritten rather than deleted, with the original reasoning kept as history. (#479)

  • The TUI rename modal says when submitting would close an open pull request. The remote half of a rename is git push --atomic origin :<old> <new>:<new>, a delete followed by a create, and GitHub closes a pull request whose head branch is renamed. There is no clean fix, only a warning: GitHub’s own rename endpoint retargets a pull request whose base is the renamed branch and closes one whose head it is, and a worktree branch is always the head of its own pull request, so both paths end in the same place; GitLab has no rename operation at all, only create-then-delete. The line is live, appearing the moment the form would write a different branch and going away when the user reverts, and Esc is the cancel. It is keyed on the branch rather than on the rename, since an edit that only moves the directory returns before touching a single ref, and it stays quiet on a merged or closed pull request. No extra forge call: the link was already resolved at list time, it is what draws the pastille. (#481)

  • Free-form worktree naming. gwm create --name spike-redis skips the <type> <issue> <desc> triple entirely: not every worktree corresponds to an issue. In the TUI, Ctrl-T toggles the create form between the structured triple and a single Name field. The flag is exclusive with the positionals, so a partial triple is still the typo it always was; the mode is chosen explicitly, never inferred from how many arguments arrived.

    The name becomes the branch verbatim, and is validated verbatim: --name " spike" is refused rather than trimmed into spike, which would be a different branch from the one asked for. branch_pattern / path_pattern do not apply, being written in terms of {type} / {issue} / {desc}, and a free-form name has none of them, while [worktree].base still does, so free-form worktrees land beside the structured ones. base is only expanded with the placeholders it documents, though: the structured path feeds {type} / {issue} / {desc} through base too, and since an unfed placeholder is left literal, a base written with one of them is refused here rather than turned into a directory called {type}.

    What a free-form worktree gives up is stated rather than discovered: issue auto-linking goes inactive (gwm link remains), gwm commit-prefix errors because a prefix is derived from the branch type and there is none, and create/remove/bootstrap hook placeholders resolve empty. PR/MR detection is unaffected: it queries the forge with the whole branch name. doctor treats the branch as user-managed and never flags it. All of which applies to a name that does not match the branch convention: nothing records how a worktree was named, only what its branch is, so --name 'feat/#42-x' is read back as structured and keeps every one of those.

    The accepted-name rules are enumerated from the three things a free-form name has to be at once, rather than accreted one example at a time. It is a git branch, validated with libgit2’s branch-level oracle, which is stricter than the reference-level one (refs/heads/HEAD is a valid reference name, HEAD is not a usable branch name). It is a single filesystem path component, which a branch name is not: no . / .. component, and at most 255 bytes, since a×130/b×130 is a legal ref and an illegal directory name and without the cap the branch is created before the directory fails, leaving it orphaned. And it is a literal value during hook expansion, so no { / }: placeholders are substituted in sequence, and spike-{issue} would have its own name rewritten inside the {branch} value a hook receives. Plus one rule belonging to none of them: no leading -, which git accepts but gwm remove and git branch -d read as a flag. Windows path rules are covered by #475 below, on every platform rather than only that one. No SCHEMA_VERSION bump: JsonWorktree carries no type / desc, so the wire format is unchanged. (#416)

  • Lifecycle hooks receive their context as environment variables, GWM_BRANCH, GWM_PATH, GWM_TYPE, GWM_ISSUE, GWM_DESC, GWM_USER, GWM_OWNER, GWM_REPO, alongside the existing {placeholder} syntax. A hook can now read "$GWM_BRANCH" and not think about quoting at all: a shell never re-parses metacharacters coming out of a variable. An explicit env entry of the same name still wins.

    This is the durable half of GHSA-fffq-vg6f-gxqm under Fixed below. The escaping makes the existing syntax safe; the variables make the safe form the one that is natural to write.

  • The TUI create and rename forms present the fields the repo’s patterns actually ask for, in the order those patterns write them, instead of the canonical Type / Issue / Desc triple whatever .gwm.toml said. A repo whose convention is {type}/{desc} is no longer shown an Issue field, and {desc}-{issue} reads top to bottom in the order it writes left to right.

    This was not cosmetic. The form demanded an issue number and then expanded a pattern with nowhere to put it: the value was mandatory and discarded, so a submission could not be composed without typing a number that went nowhere. The submit now validates only the segments the patterns carry.

    The field set is the union over branch_pattern, path_pattern and base, because base expands the same three tokens: a segment only it carries still names a real directory on disk.

    The field set is derived after {repo} and {home} resolve, the way the formatter does it, because a repo whose name itself contains {type} makes {repo}/{desc} write a type the raw pattern never mentions.

    Everything the form advertises follows the same source: the hint row drops ↑/↓ type where no pattern carries {type} and field where there is one field, and the status line names the field Enter actually submits from. Where a pattern presents no editable token at all the form says enter: submit and names nothing.

    Two consequences worth stating. The type selector now sits below the live preview rather than above it, so that visual order matches focus order in every pattern. And the rename form’s refusal is rescoped rather than removed: it used to turn away any branch whose pattern omits a segment, which took the form away from a repo that simply does not use that segment. It now refuses exactly the case where a segment is written somewhere the form cannot read back, worktree.base being the one that matters, since opening there would show a default and submitting would write it over the real value on disk. (#418)

  • The declared MSRV is now 1.95, up from 1.86, and CI holds it from now on.

    Cargo.toml claimed 1.86 while nothing verified the claim, and two separate blind spots kept it that way. The clippy job only catches a std API used above the declared floor, never a dependency raising its own; and cargo metadata, the obvious way to read the graph’s floor, only reports crates that declare a rust-version at all. Read through metadata, the locked graph asks for 1.88 (the ratatui 0.30 stack, time 0.3.47). Compiled, it asks for 1.95: libsqlite3-sys 0.38.1, a normal dependency pulled in by rusqlite with bundled, declares no rust-version whatsoever and its build script uses cfg_select!, stable only since 1.95.0. A crate that declares nothing is invisible to every metadata-based check. Only a build finds it.

    Both sides were measured against the committed lockfile: cargo +1.94 check --all-targets --locked fails with error[E0658]: use of unstable library feature 'cfg_select', and cargo +1.95 check --all-targets --locked passes. libsqlite3-sys 0.38.1 was already locked at the v1.5.0 tag, so this raises the declared floor to what the code has in fact required since before that release; it does not raise the real one.

    Holding a lower floor was considered and dropped: rusqlite 0.40.1 requires libsqlite3-sys ^0.38.1, so it would mean downgrading rusqlite itself. (#491)

  • A msrv job now runs on every push and pull request. It reads rust-version out of Cargo.toml, installs exactly that toolchain, and runs cargo check --all-targets --locked, which both fires cargo’s own rust-version gate at resolve time and compiles the dependencies that declare no floor at all. The toolchain is derived rather than hardcoded, so the job cannot drift from the manifest the way the manifest drifted from the graph. A companion test pins the ten user-facing places that advertise the MSRV to whatever Cargo.toml declares. (#491)

  • A gwm create that fails while making the worktree no longer leaves the branch behind. worktree::add has to create the branch before it calls repo.worktree, because libgit2’s WorktreeAddOptions::reference takes a reference that already exists, so any failure past that point left a branch nobody asked for, pointing at the old HEAD, surfaced only by gwm doctor’s orphan check or by a git branch -D on a name the error printed in passing.

    It is now deleted on the failure path, and only when that same call created it: a branch attached with --reuse-branch predates the command, and deleting it would destroy work. The predicate is “this call created it” rather than “reuse was off”, because gwm undo and gwm review both pass the reuse flag against a branch that is usually absent, and then create it. The creation stamp add wrote goes too, so it cannot age the next branch to take that name, but the rest of the branch.<name> section stays put: that section outlives its ref easily, and deleting the branch rather than the ref would have taken an upstream setting the command never wrote.

    The error stays the underlying failure rather than a cleanup message. One window is left alone on purpose: once libgit2 has bound the worktree to the branch, which it does just before the checkout, the branch stays. Deleting it there leaves a worktree pointing at a ref that no longer exists, and unlike an orphan branch that residue is reported by nothing, so a failure that late behaves as it did before.

    This bounds the class that #474 (the 255-byte cap) and #475 (the Windows character and device set) each closed one input set of. Refusing a name up front is worth having on its own, but a full disk is not an input set.

  • Config values no longer reach the terminal with their control bytes intact. A .gwm.toml comes from a repo nobody has vetted, and the commands that read it back skip the trust gate on purpose, because inspecting an unfamiliar repo before trusting it is meant to be the safe move. Echoing a value verbatim handed that file a terminal escape channel out of a read-only command: an OSC 52 clipboard write, a window-title rewrite, cursor moves that erase the line above.

    gwm config get, config list (its keys, which are attacker-chosen wherever the schema is a map), types, aliases list, doctor, commit-prefix, trust list / show / add / revoke, the milestone and label diff rows, and the TOFU prompt are all covered. So is every error message, which needed no echo command at all: toml’s parse error quotes the offending source line verbatim, so running gwm list inside the repo was enough.

    The one that matters most is the prompt: its bootstrap summary renders directly above Trust this .gwm.toml? [y/N/show]:, so a cursor-up-and-erase in a [[bootstrap.command]] name could delete the row naming the shell it was asking permission to run.

    Values are replaced rather than stripped, so what is left stays recognisable and no length changes silently. Errors keep their line breaks, because a parse diagnostic points at the broken column across several rows, but every line after the first now sits under gwm’s own margin. That is what stops a value carrying a newline from printing a second line at column zero that reads like a statement from gwm rather than a quote from the repo.

    One value is refused rather than neutralised: an [aliases] expansion becomes argv before the CLI parser runs, and that parser prints its own errors without passing through gwm’s output path, so a control character there is rejected when the config loads.

  • worktree patterns are expanded in a single pass, so an expansion is a value rather than more template. expand_placeholders chained str::replace calls and each one re-scanned what the previous had written, with {home} and {repo} going first: on a repo whose own directory is named api-{type}, branch_pattern = "{repo}/{desc}" wrote api-fix/foo and carried a branch type the pattern never mentions.

    Anything that reasons about a pattern by reading it was wrong there, and two things do. The create form derived its fields from the wrong set, and the branch parser could not mirror a formatter that rewrote its own output, so it refused the whole class rather than build a parser recognising none of the branches the pattern creates. Those patterns now compile and round-trip.

    Two behaviours are preserved rather than tidied away: a token with no value stays literal instead of collapsing to empty, which is what lets {repo_path} and {repo_parent} survive into a branch name; and the token starts at the last { before the closing brace, so {{type} keeps writing {feat as it did in 1.5.0.

    Hook placeholders were already single-pass and are unaffected. (#494)

  • gwm pr, gwm commit-prefix and gwm bootstrap read the branch of the worktree they were pointed at, instead of the main checkout’s. worktree::discover_repo walks back to the main working directory when it lands inside a linked worktree, which is what list / remove / switch / prune need, since they operate on the whole worktree set. Asking that handle “which branch am I on” answered for the main checkout.

    gwm commit-prefix was the one that hurt: the bundled commit-msg hook calls it and git invokes that hook with the working directory inside the worktree, so every commit made from a worktree took its prefix from whatever the main checkout happened to be sitting on. gwm pr had a quieter symptom, falling back to the chore template because the main branch did not parse.

    A third site turned up on the sweep, with a different symptom: gwm bootstrap only ever resolved a branch when its target came from a fuzzy pattern, so hooks got an empty {branch} / {type} / {issue} rather than the wrong one. Two of its three target paths were affected, no argument and an argument that is already a directory.

    Only the branch moves. .gwm.toml, the workdir and the repo name still come from the main checkout, which is where the config lives and what {repo} expands to, so a branch written with {repo} in branch_pattern keeps being read back correctly from inside a worktree. gwm status was right all along, and its resolve_target_repo is the shape the fix follows.

    Pre-existing rather than a regression, measured against the published 1.5.0 binary. (#477)

  • A free-form worktree name is validated against Windows path rules, on every platform. gwm create --name 'foo|bar' or --name CON used to pass every check and fail inside worktree::add, after pre_create hooks had run and with the branch already created, leaving it orphaned. Same late-failure shape the 255-byte cap closed, on a platform-specific input set that only free-form naming can reach.

    The residual list is measured against libgit2’s oracle rather than copied off the Win32 page. Of the nine characters Win32 forbids in a path component, Branch::name_is_valid already refuses :, \, ? and *, and / is the ref separator gwm flattens to -, so <, >, " and | are what is left. Windows also refuses a trailing space or period, and the two are not symmetric: git refuses a space in every position, but its own trailing-period rule covers the whole branch name rather than each component, so spike. is git’s to refuse and foo./bar is gwm’s. What git knows nothing about at all is the reserved device names (CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9, plus the ISO 8859-1 superscript forms Windows reads as digits): compared case-insensitively on the stem before the first ., since Win32 documents NUL.tar.gz as equivalent to NUL. COM0 and LPT0 are absent from that list and stay legal.

    Checked per /-separated segment rather than on the flattened directory name alone, because a loose ref is a file at .git/refs/heads/<name>: spike/CON becomes the legal directory spike-CON and an unwritable ref.

    The rule is unconditional, not #[cfg(windows)]. A branch reaches a teammate through the forge, so a name no Windows checkout can host is a cross-platform hazard; and a rule only one CI runner exercises is the shape this repo’s own guidance on environment-dependent tests argues against. Tightening now is free because --name has not shipped to a stable line yet. (#475)

  • The TUI names a workspace repo by its directory, not by its display label. workspace::discover suffixes a repo whose basename collides with a sibling, so a second api shows as api-2, and activating it put that label into the field every formatter call expands {repo} with. The parser side reads the directory basename and so does every gwm create from the CLI, so in a workspace with such a collision a {repo} pattern wrote api-2/... that neither could read back: no issue auto-linking, no gitmoji, commit-prefix erroring on a branch gwm had just created. The label is a property of the workspace’s current membership rather than of the repo, so it changes when a sibling moves while branches already written do not, and a name persisted in git must not depend on what else sits next to it on disk. Naming uses the basename everywhere now; the label stays what it was built for, the header and the REPO column. Two repos sharing a basename consequently share a {repo} base directory, which is what the CLI has always done, and a collision there is a loud target path already exists rather than a silent one. (#480)

  • gwm doctor and gwm config validate now warn when worktree.branch_pattern does not survive a format-then-parse round-trip. The pattern drives how a branch name is written but not how one is read back (the parser is still a hardcoded regex) so a mismatched pattern silently broke issue/PR auto-linking, gitmoji selection, lifecycle hook placeholders and the branch-convention check. The warning names whichever segments actually break: a custom pattern is not automatically a broken one ({type}/#{issue}-prefix-{desc} still recovers type and issue, only desc comes back wrong), and claiming otherwise would defeat the point of a warning whose whole value is accuracy. The probe enumerates the value space gwm create admits rather than sampling it: every configured branch type, a single- and a multi-digit issue, a desc with and without the - it allows, and the real repo name for {repo}, so a pattern that breaks on only some values is reported as breaking on only some values. gwm config validate prints it on stderr, reads the effective pattern so one set only in the global ~/.config/gwm/config.toml is caught too, and still exits 0: a custom pattern is valid configuration, not an error (gwm doctor reports it as a ! check, so that command exits 1 like any other Warning). The config-supplied pattern is neutralised for control characters before it is echoed: neither command goes through the trust gate, so an unvetted .gwm.toml must not get a terminal escape channel out of a health check. This states the limitation; the entry below removes its cause, so the set of patterns it has anything to say about is much smaller than it was. (#415)

  • worktree.branch_pattern is now read back by a parser compiled from that same pattern, so customising it no longer disables the features that re-read a branch name. The pattern drove how a branch was written while a hardcoded ^([a-z]+)/#(\d+)-([a-z0-9-]+)$ decided how one was read, so a repo that set branch_pattern = "{type}-{issue}-{desc}" created feat-41-foo and then failed to recognise the branch it had just created: no issue auto-linking, no gitmoji, gwm commit-prefix erroring, empty hook placeholders on the remove / bootstrap paths, a rename modal that refused to open, and a doctor orphan check that skipped every branch as user-managed. One source of truth now, so the conventions people actually use keep all of it: {type}-{issue}-{desc}, {type}_{issue}_{desc}, {type}/{issue}-{desc}, wt/{type}/#{issue}-{desc}, {desc}/#{issue}-{type} and a literal wedged anywhere all round-trip.

    A pattern whose split can move is refused rather than compiled into a parser that reads back the wrong thing. The test is never “is there a separator” but “can the boundary between two placeholders land in more than one place”: {issue}{desc} reads 42123-x as 4212 + 3-x, {desc}{issue} is ambiguous outright since a12 is what both a + 12 and a1 + 2 produce, and a non-empty separator guarantees nothing either: {type}-{issue}9{desc} writes feat-42919x from issue 42 and desc 19x, and the greedy \d+ slides right across the 9 to read issue 4291.

    Both halves of the rule are narrower than they look, so patterns that read back perfectly well are not refused along the way. Adjacency is fine when the alphabets are disjoint ({type}{issue} writes feat42, and [a-z]+ stops at the first digit while \d+ stops at the first letter). A separator inside the left placeholder’s charset is fine when the right one cannot supply it back, which is why {desc}-{issue} stays legal. And a multi-character separator only counts when the left side can eat a repeating prefix of it, which is why {type}-{issue}9-{desc} works where {type}-{issue}9{desc} does not. The same placeholder twice is refused too. gwm doctor and gwm config validate report every refusal with the fix in it.

    The parser is checked against the formatter rather than argued to mirror it: compile writes one probe branch with expand_placeholders and refuses the pattern when it cannot read that back. {repo} / {home} expansions are substituted again by the formatter, so a repo directory named {type} (or named type under a {{repo}} pattern) made every read-back feature go quiet with nothing saying why; the check closes that class rather than its two known instances. A ~ prefix stays out of it, because shellexpand::tilde is a divergence no parser can undo, and gwm doctor already names every feature it takes down.

    The rule is pinned by enumeration rather than by examples: a test generates every pattern over the three placeholders and a set of separators, decides independently whether each one round-trips, and requires the compiler to accept exactly those, so neither a silent mis-split nor an over-strict refusal can survive.

    A pattern that freezes a segment as a literal instead of writing it from a placeholder keeps working exactly as before. feat/#{issue}-{desc} and {type}/#1-{desc} were readable in 1.5.0 only because the hardcoded regex happened to have a group where the literal sits; the derived parser recovers the literal on purpose, so gitmoji, gwm commit-prefix and auto-linking still work on those repos. The recovery is an exact match rather than a guess. It is positional first, in that a literal is only read as a segment if it sits where that segment goes, before {issue} for a type and after it for a description, and then an exact match, a branch type being looked up in the repo’s configured list (so feature/#{issue}-{desc} recovers nothing, since feature names a namespace), an issue number being all digits, and a description being whatever DESC_RE accepts. Position has to come first: feat/#{issue}-fix freezes both, and feat and fix are each a configured branch type, so an oracle asked to pick a globally unique candidate found two and dropped the pair. What is left over after position is decided per segment: a segment is recovered when every reading of the pattern names it with the same value, so feat/feat/#{issue}-{desc} freezes the type its two readings agree on, and feat/#{issue}-fix/done freezes the type while leaving the description its readings disagree about alone. The whole obligation is enumerated rather than sampled: 1.5.0 read a branch iff it matched one hardcoded regex, so a test runs that regex over every pattern in the family it accepts and requires the same triple back. One divergence in that family is deliberate: 1.5.0’s description group was [a-z0-9-]+, looser than DESC_RE, so {type}/#{issue}---fix handed back --fix, a description its own BranchSpec::validate rejects, which the rename form could not submit and gwm create could never have produced. The leading dashes are dropped, and a leading - is in any case what #416 banned from a name, since gwm remove and git branch -d read it as a flag. {repo} is deliberately not a source, so a repo called docs does not type its own branches. What such a pattern costs is reported separately and unchanged from #415: gwm create fix 42 x under feat/#{issue}-{desc} writes a feat/ branch, so the type you asked for is not the one anyone reads back. The TUI rename form shows a frozen segment, and whether it can be changed depends on where the new value could go. That is the formatter’s question, so all three patterns it expands are asked: branch_pattern, path_pattern and [worktree].base. The path pattern writing the segment means editing it renames the directory; base writing it means the worktree moves between base directories, which is what a base of .../{type} is for. Either way the branch is left alone, which is a real rename, and the preview says so by showing the branch unchanged. Only when none of the three writes the segment is the edit refused, since the submit would rebuild the same branch at the same path. Segments branch_pattern writes are always editable, so the rename that worked on feat/#{issue}-{desc} before #417 still works.

    Both live previews expand this repo’s own patterns too. They hardcoded <type>/#<issue>-<desc> and <type>-<issue>-<desc>, so under a custom pattern they promised names the repo would never create: with feat/#{issue}-{desc}, picking docs in the rename type selector previewed docs/#42-x while submitting wrote feat/#42-x. A preview that disagrees with what submitting does is worse than no preview at all.

    The value that form shows comes from the worktree’s directory when path_pattern carries the segment and branch_pattern does not. The two patterns need not carry the same segments, and when they do not, neither name holds the whole triple: under feat/#{issue}-{desc} with the default path_pattern, gwm create fix 42 x writes the branch feat/#42-x and the directory fix-42-x, and fix exists nowhere else. Rebuilding from the branch alone read the type as feat, so renaming the description also moved the directory to feat-42-… and dropped what the worktree was created with. The branch still wins for every segment it writes itself, being the worktree’s identity, and a directory renamed by hand must not rewrite it. (#478)

    {type} matches [a-z]+, not an alternation of the configured branch types, which is what the issue proposed. The alternation would have stopped recognising a branch created before a type was retired from .gwm.toml, taking doctor’s orphan check and gwm commit-prefix away from a name the previous release read fine. Nothing needs it either: once adjacent placeholders are refused, [a-z]+ splits every pattern in the documented table, and the TUI rename, which does require a configured type, checks the resolved list itself and says so precisely. (#417)

  • Security, GHSA-fffq-vg6f-gxqm (high, CWE-78 / CWE-88): a branch name could inject a command into a lifecycle hook. lifecycle::run_step substituted the hook placeholders into the step’s run string and handed the result to sh -c. {branch} gets its value from git, and git permits ;, |, &, $, backticks, parentheses and redirections in a ref name, none of which were escaped. A branch name could therefore end the hook’s command and start another one, running as the user, in their repo, with their credentials on disk.

    What makes that a vulnerability rather than the hook surface behaving as designed: a .gwm.toml hook is an RCE primitive behind the TOFU trust gate, and that part is deliberate. The branch name is data, not code. Someone who legitimately trusted their own repo’s hooks could be attacked through a branch they never wrote, pushed by someone else (a fork PR branch is enough), with no trust prompt anywhere in the path: the gate asks whether you trust this repo’s hooks, and never covered the branch name flowing into them.

    Affected: every version up to and including 1.5.0. Reproduced against the released 1.5.0 binary on a branch created by plain git worktree add -b, so it predates the free-form naming work rather than being introduced by it. Found by the Codex review loop on PR #474. Impact, threat model and reproduction are in the advisory.

    The fix: placeholder values are shell-escaped when they are expanded into a hook’s run script. env values are deliberately left unescaped, since they go to the process environment and never see a shell, so escaping them would put literal quote characters into what the hook reads back. [[bootstrap.command]] steps fold into the same path and get the same treatment.

    Expansion is also single-pass now. Chained replacements re-scanned what the previous one wrote, so a branch named spike-{issue} had the token inside its own name rewritten, and with escaping in play that would have spliced quote characters into the middle of another value.

    An empty placeholder is left alone rather than escaped. It has nothing to inject, and shell_words::quote("") is '', so escaping it would mean mycmd {issue} started passing an empty argument where it passed none, on every branch that does not match the convention. Hooks therefore see no change at all beyond the one this fixes.

  • changelogs/1.5.0.md was corrected after the v1.5.0 tag: its caveat said the GitLab backend had been verified from glab’s documentation, when it had in fact been driven end to end by the real glab 1.109.0 binary against a local fake GitLab server. The published release body was re-sourced from the corrected file with gh release edit --notes-file, so this diff on an archived version file is deliberate and already live on the release page. The v1.5.0 tag itself is untouched.