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 withgwm create --name spike-rediscould not be renamed from the TUI at all:worktree_specstarts 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-Tmoves 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 appliesbranch_patternandpath_patternto the triple; structured to structured is unchanged.The four cells are two code paths, not four.
WorktreeNamealready 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
kebabof 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::freeformrules asgwm create --name, so a name one form refuses the other refuses too. Two guards come along with the change. Aworktree.basewritten 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 guardenter_confirm_deleteuses.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, andEscis 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-redisskips the<type> <issue> <desc>triple entirely: not every worktree corresponds to an issue. In the TUI,Ctrl-Ttoggles the create form between the structured triple and a singleNamefield. 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 intospike, which would be a different branch from the one asked for.branch_pattern/path_patterndo not apply, being written in terms of{type}/{issue}/{desc}, and a free-form name has none of them, while[worktree].basestill does, so free-form worktrees land beside the structured ones.baseis only expanded with the placeholders it documents, though: the structured path feeds{type}/{issue}/{desc}throughbasetoo, 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 linkremains),gwm commit-prefixerrors 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.doctortreats 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/HEADis a valid reference name,HEADis 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, sincea×130/b×130is 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, andspike-{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 butgwm removeandgit branch -dread as a flag. Windows path rules are covered by #475 below, on every platform rather than only that one. NoSCHEMA_VERSIONbump:JsonWorktreecarries notype/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 explicitenventry 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/Desctriple whatever.gwm.tomlsaid. 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_patternandbase, becausebaseexpands 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
↑/↓ typewhere no pattern carries{type}andfieldwhere 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 saysenter: submitand 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.basebeing the one that matters, since opening there would show a default and submitting would write it over the real value on disk. (#418)
Changed
Section titled “Changed”-
The declared MSRV is now 1.95, up from 1.86, and CI holds it from now on.
Cargo.tomlclaimed 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; andcargo metadata, the obvious way to read the graph’s floor, only reports crates that declare arust-versionat 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 byrusqlitewithbundled, declares norust-versionwhatsoever and its build script usescfg_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 --lockedfails witherror[E0658]: use of unstable library feature 'cfg_select', andcargo +1.95 check --all-targets --lockedpasses.libsqlite3-sys 0.38.1was already locked at thev1.5.0tag, 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.1requireslibsqlite3-sys ^0.38.1, so it would mean downgrading rusqlite itself. (#491) -
A
msrvjob now runs on every push and pull request. It readsrust-versionout ofCargo.toml, installs exactly that toolchain, and runscargo check --all-targets --locked, which both fires cargo’s ownrust-versiongate 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 whateverCargo.tomldeclares. (#491)
-
A
gwm createthat fails while making the worktree no longer leaves the branch behind.worktree::addhas to create the branch before it callsrepo.worktree, because libgit2’sWorktreeAddOptions::referencetakes a reference that already exists, so any failure past that point left a branch nobody asked for, pointing at the old HEAD, surfaced only bygwm doctor’s orphan check or by agit branch -Don 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-branchpredates the command, and deleting it would destroy work. The predicate is “this call created it” rather than “reuse was off”, becausegwm undoandgwm reviewboth pass the reuse flag against a branch that is usually absent, and then create it. The creation stampaddwrote goes too, so it cannot age the next branch to take that name, but the rest of thebranch.<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.tomlcomes 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 runninggwm listinside 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. -
worktreepatterns are expanded in a single pass, so an expansion is a value rather than more template.expand_placeholderschainedstr::replacecalls and each one re-scanned what the previous had written, with{home}and{repo}going first: on a repo whose own directory is namedapi-{type},branch_pattern = "{repo}/{desc}"wroteapi-fix/fooand 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{featas it did in 1.5.0.Hook placeholders were already single-pass and are unaffected. (#494)
-
gwm pr,gwm commit-prefixandgwm bootstrapread the branch of the worktree they were pointed at, instead of the main checkout’s.worktree::discover_repowalks back to the main working directory when it lands inside a linked worktree, which is whatlist/remove/switch/pruneneed, since they operate on the whole worktree set. Asking that handle “which branch am I on” answered for the main checkout.gwm commit-prefixwas the one that hurt: the bundledcommit-msghook 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 prhad a quieter symptom, falling back to thechoretemplate because the main branch did not parse.A third site turned up on the sweep, with a different symptom:
gwm bootstraponly 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}inbranch_patternkeeps being read back correctly from inside a worktree.gwm statuswas right all along, and itsresolve_target_repois 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 CONused to pass every check and fail insideworktree::add, afterpre_createhooks 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_validalready 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, sospike.is git’s to refuse andfoo./baris 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 documentsNUL.tar.gzas equivalent toNUL.COM0andLPT0are 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/CONbecomes the legal directoryspike-CONand 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--namehas not shipped to a stable line yet. (#475) -
The TUI names a workspace repo by its directory, not by its display label.
workspace::discoversuffixes a repo whose basename collides with a sibling, so a secondapishows asapi-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 everygwm createfrom the CLI, so in a workspace with such a collision a{repo}pattern wroteapi-2/...that neither could read back: no issue auto-linking, no gitmoji,commit-prefixerroring 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 theREPOcolumn. 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 loudtarget path already existsrather than a silent one. (#480) -
gwm doctorandgwm config validatenow warn whenworktree.branch_patterndoes 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 recoverstypeandissue, onlydesccomes back wrong), and claiming otherwise would defeat the point of a warning whose whole value is accuracy. The probe enumerates the value spacegwm createadmits 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 validateprints it on stderr, reads the effective pattern so one set only in the global~/.config/gwm/config.tomlis caught too, and still exits0: a custom pattern is valid configuration, not an error (gwm doctorreports it as a!check, so that command exits1like 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.tomlmust 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_patternis 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 setbranch_pattern = "{type}-{issue}-{desc}"createdfeat-41-fooand then failed to recognise the branch it had just created: no issue auto-linking, no gitmoji,gwm commit-prefixerroring, empty hook placeholders on the remove / bootstrap paths, a rename modal that refused to open, and adoctororphan 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}reads42123-xas4212+3-x,{desc}{issue}is ambiguous outright sincea12is what botha+12anda1+2produce, and a non-empty separator guarantees nothing either:{type}-{issue}9{desc}writesfeat-42919xfrom issue42and desc19x, and the greedy\d+slides right across the9to read issue4291.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}writesfeat42, 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 doctorandgwm config validatereport every refusal with the fix in it.The parser is checked against the formatter rather than argued to mirror it:
compilewrites one probe branch withexpand_placeholdersand 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 namedtypeunder 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, becauseshellexpand::tildeis a divergence no parser can undo, andgwm doctoralready 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-prefixand 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 (sofeature/#{issue}-{desc}recovers nothing, sincefeaturenames a namespace), an issue number being all digits, and a description being whateverDESC_REaccepts. Position has to come first:feat/#{issue}-fixfreezes both, andfeatandfixare 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, sofeat/feat/#{issue}-{desc}freezes the type its two readings agree on, andfeat/#{issue}-fix/donefreezes 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 thanDESC_RE, so{type}/#{issue}---fixhanded back--fix, a description its ownBranchSpec::validaterejects, which the rename form could not submit andgwm createcould never have produced. The leading dashes are dropped, and a leading-is in any case what #416 banned from a name, sincegwm removeandgit branch -dread it as a flag.{repo}is deliberately not a source, so a repo calleddocsdoes not type its own branches. What such a pattern costs is reported separately and unchanged from #415:gwm create fix 42 xunderfeat/#{issue}-{desc}writes afeat/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_patternand[worktree].base. The path pattern writing the segment means editing it renames the directory;basewriting it means the worktree moves between base directories, which is what abaseof.../{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. Segmentsbranch_patternwrites are always editable, so the rename that worked onfeat/#{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: withfeat/#{issue}-{desc}, pickingdocsin the rename type selector previeweddocs/#42-xwhile submitting wrotefeat/#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_patterncarries the segment andbranch_patterndoes not. The two patterns need not carry the same segments, and when they do not, neither name holds the whole triple: underfeat/#{issue}-{desc}with the defaultpath_pattern,gwm create fix 42 xwrites the branchfeat/#42-xand the directoryfix-42-x, andfixexists nowhere else. Rebuilding from the branch alone read the type asfeat, so renaming the description also moved the directory tofeat-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, takingdoctor’s orphan check andgwm commit-prefixaway 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_stepsubstituted the hook placeholders into the step’srunstring and handed the result tosh -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.tomlhook 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
runscript.envvalues 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 meanmycmd {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.mdwas corrected after thev1.5.0tag: its caveat said the GitLab backend had been verified fromglab’s documentation, when it had in fact been driven end to end by the realglab1.109.0 binary against a local fake GitLab server. The published release body was re-sourced from the corrected file withgh release edit --notes-file, so this diff on an archived version file is deliberate and already live on the release page. Thev1.5.0tag itself is untouched.