v1.7.0
-
Inline review comments in the rich PR view (#528). The comments anchored to a diff hunk now render in the
Iview, under the reviews: one section per thread, showing its anchor (src/tui/app.rs:7-11, markedresolved/outdatedwhen it applies), the diff hunk it hangs from, and the reply chain in order. These are reachable through GraphQL alone (gh pr view --json commentsreturns the conversation), so they travel on a second request, fired when the view opens rather than alongside every PR fetch:gwm statuscalls the same fetch on every invocation and has no use for them. Hunk lines are truncated instead of wrapped, since a wrapped+line’s continuation would carry no sigil and read as context, and a long hunk drops its head rather than its tail because the anchored line is the last one. Every cap states its count from the forge’s own totals, and each comment header keeps its permalink soEnteropens the full thread. On a GitLab remote the section is present and says the backend cannot reach these, which is a different fact from “there are none”: GitLab carries them as discussion notes withpositiondata, under a different request and a different shape, deliberately out of scope here. -
Rich PR / issue view in the TUI (#420).
Iopens the linked PR (or the linked issue when there is no PR) on its description, author, branch pair, diff size, CI rollup, submitted reviews and conversation, without leaving the TUI.gwmalready askedghfor the rollup and threw the per-check detail away; it now asks forbody,author,comments,reviews,additions/deletionsand the branch refs in the same single request, so the view costs no extra round trip. Bodies are wrapped against the modal width and re-wrapped on resize, and every cap says how much it dropped (… 312 more lines) rather than stopping silently. Remote text is neutralised on the way in, so a bidi override in a comment cannot reorder what the terminal paints.Enteropens the selected row’s URL,fre-fetches, and the whole verb set is rebindable under[tui.keys.modal.rich_view]. On GitLab the view renders the summary tier plus the description, author and branch pair; approvals, notes and the diff size need separate API calls and are not fetched. -
Container execution on
gwm execprofiles (#421). A[exec.profiles.<name>.container]block wraps that profile’s command indocker run/podman run:imageis required,runtimeis auto-detected (docker first, then podman) andextra_argsis spliced in before the image. The block rides a profile only: an inlinegwm exec -- <cmd>still runs on the host, whatever the config says, so the frozen 1.0 surface keeps doing what it did. The mount is the point rather than the wrapper: a linked worktree’s.gitis a file holding an absolute host path, so gwm mirrors host paths and mounts the main checkout’s gitdir alongside (-v <worktree>:<worktree> -v <main>/.git:<main>/.git -w <worktree>), which is what makesgit status, a commit or a hook answer inside the container; mounting the worktree alone produces one where none of them do. gwm builds an argv and never a shell string, no token is quoted or joined at any point, and the per-worktree header names the run (━━ feat-1 (/path) [docker rust:1.90]). Any Docker-compatible CLI works (OrbStack, Colima, Rancher Desktop, Docker Desktop, native Docker), so there is no runtime to integrate. Every mounted path is declaredsafe.directorythroughGIT_CONFIG_*environment (never the blanket*), because a rootful Docker on Linux runs as uid 0 against a tree owned by the host user and git would otherwise refuse it asdubious ownership, undoing the mount.gwm execallocates no TTY, since a terminal per container means nothing across a fan-out; the TUI exec overlay, which spawns into a real pty, runs the container with-i -tso a REPL or a debugger keeps working there. The TUI overlay also names its container (gwm-<worktree>-<pid>-<n>) and removes it on close from the worktree, because killing adocker runclient leaves the container running and a long command would keep writing to the worktree after the overlay closed;--nameinextra_argsis refused, since a runtime honours the last one and the teardown would then remove something else.selinux_relabel = truesuffixes gwm’s own mounts with:zfor an SELinux-enforcing host, whichextra_argscannot reach; it stays off by default because relabelling writes to the host recursively. Refused on Windows with a message saying why (host paths cannot be mirrored into a Linux container, and a linked worktree’s.gitfile would still name a drive-letter path), and refused for a worktree path containing a:, which is the field separator of-v source:destination. -
Multi-row selection in the TUI, and a batch delete on top of it (#484).
Spacemarks the highlighted worktree,dthen deletes every marked row in one batch; with nothing marked it stays the single-row delete it has always been. Onlydreads the mark set, so the worktrees footer carries the count (3 of 12 · 2 marked) rather than letting a live selection go invisible underb/s/p. Marks are keyed by on-disk path, which is what makes them survive the fuzzy reranking and stay unambiguous in workspace mode, where two repos can hold the same worktree id. Opening the filter and the manualfrefresh clear them; the background auto-refresh only prunes rows that no longer exist, otherwise a 60s timer would eat a selection still being built. The confirm overlay snapshots its targets when it opens, so a refresh landing during the safety countdown cannot retarget the deletion, and for a batch it reports the size and how many targets carry a branch instead of listing rows, withDarming the branch deletion batch-wide. A batch never stops at the first error: every target is attempted through its own repo handle and only after re-checking that its id still resolves to the path the overlay named (a worktree removed and recreated from another shell during the countdown gets the same id back, and removing by id alone would have deleted it), the confirm stays open narrowed to what failed (narrowed, never recomputed:worktree::removeprunes the admin entry before deleting the directory, so a removal that fails on the filesystem drops its own row, and recomputing would have fallen back to the cursor row), and the status line names the failures. -
gwm removetakes several patterns (#484).gwm remove a b cremoves the batch in one command and--dry-runprints one plan per pattern. Every pattern is resolved before anything is touched, so an unknown or ambiguous one fails the whole command with nothing removed, which is whatgwm list --format json | ... | xargs -n1 gwm removecould not do: it removed the first half of the batch and then reported the typo. Patterns naming the same worktree collapse to a single removal. -
symfonyconfig preset (#392). A seventhgwm init --preset, next tolaravelon the composer side but built on Symfony’s own dotenv convention, which is the mirror image of Laravel’s:.envis committed and holds the neutral defaults,.env.localis gitignored and holds the secrets. So the preset copies.env.localand.env.test.localrather than.env, and theno-aws-rdsguard seeds from the committed.envinstead of an.env.example.var/joinsvendor/in the no-symlink invariants, because it holds the compiled service container and the cached routes: sharing it between two worktrees running different code is worse than a slow first request.composer installanddirenv allow .run on the samewhenpredicates as the Laravel preset. -
Per-worktree notes (#515). gwm knew the branch, the linked issue, the diff against base and the agent session, but not where you were: what you had just figured out, what is blocking, what to check before opening the PR.
Nnow opens the selected worktree’s note in an editable modal, and the worktrees table carries a binary marker on the rows that have one. The modal rather than$EDITOR, because a note is usually three lines written in the ten seconds between two thoughts and suspending the whole TUI to spawn an editor is a heavier gesture than that;Ctrl+einside it still hands the same file over (editor_cmd, then$EDITOR, thenvi, the handoffouses inmode = "editor") and reloads what that editor wrote.Escwrites and closes, with no “quit without saving” to lose prose to: emptying the buffer is how a note is deleted, and it removes the file rather than leaving a blank one. Text,Enter,Backspace,Delete, the arrows,Home/EndandPageUp/PageDownare all input and none of them are rebindable: onlycloseandopen_editorlive under[tui.keys.modal.note], and binding either to a printable is refused at load time. Notes are plain Markdown at<main-checkout>/.git/gwm/notes/<branch>.md: greppable and editable with gwm shut down, never committed, readable from the main checkout, and they survivegwm remove, which is the point of keeping them out of the worktree.gwm note show [slug]prints one on stdout and exits 1 when there is none, so it doubles as a presence test in a script, and the--format=jsonlist rows carry an additivenotefield alongside the daemon payload. The note is keyed on the branch, which has four stated consequences: a detached row cannot carry one and says so instead of no-oping; a rename moves the file with the branch; a branch name a filesystem will not take backs no note anywhere, on every platform, so a note cannot exist on macOS and vanish on the same repo cloned to Windows; andgwm doctorreports a note whose branch is gone, rather thangwm clean, whose safety property is that--yesonly removes directories git already ignores. Two branch names a volume folds together (whichgit pack-refslets coexist) share one file, soNrefuses the pair rather than opening one branch’s editor on the other’s prose: by name before either has a note, and by asking the filesystem once one does, which is the only thing that knows whether this volume foldsfeat/éontofeat/Éor an NFC name onto its NFD twin. Presence means “non-blank”, not “the file exists”, becauseviover an empty buffer writes one byte.Nalso sits in the worktrees which-key, betweenagentsandreview, since the footer is the only place a verb is discoverable without opening?first. One upgrade note, the same onezcarried in #484:Nis now a shipped default, so a.gwm.tomlbinding a chord starting withN(saytop = ["N x"]) is a prefix conflict and is refused at load time. Rebind that chord, or moveedit_noteelsewhere.
Changed
Section titled “Changed”- The TUI delete runs the remove hooks and records the undo journal
(#521).
dcalledworktree::removedirectly, so[hooks.pre_remove]/[hooks.post_remove]never ran and nothing reachedgwm undo: an interactive delete was unrecoverable, and a hook written to guard a removal only held for whoever used the CLI. Both paths now go through one sequence, sogwm undoputs back whatdremoved, exactly as it does forgwm remove.undostays per-worktree: a batch of ten appends ten entries and pops one at a time. Two consequences worth knowing before you upgrade. Apre_removethat refuses now refuses in the TUI too, for that one target, with the batch carrying on and the confirm overlay staying open on what failed. And because running a hook means executing code out of.gwm.toml, a delete in a repo whose config has remove hooks is gated on the TOFU trust ledger (#95): unapproved, it refuses rather than silently skipping the guard hook. The gate asks about the two remove phases only, so a config whose hooks are allpost_createruns nothing on a delete and is never asked. Hook output lands on the Command Logs transcript (3) as it does everywhere else. - A refused removal is no longer recorded as undoable
(#521,
#531).
gwm removewrote its journal entry before the destructive call, so a target that then got refused (its path moved since it was resolved, git declining the removal) still showed up ingwm historyas somethinggwm undowould replay. That is not a cosmetic leftover:gwm undosaves the journal only once the resurrection succeeds, and the resurrection fails on a worktree that is still on disk, so the stale entry came back on every retry and no later removal in that repo could be undone untilhistory.tomlwas edited by hand. The removal now records itself at its point of no return, with the worktree gone and the branch not yet, which is both after everything that can still refuse and before the one step that destroys something unrecoverable: a refusal writes nothing, and a partial failure still leaves the branch OIDgwm undoneeds. - The delete reads
.gwm.tomlonce (#531). A TUI delete asked the same file four separate times: for the config whose hooks run, for the repo layer that says whether it runs any, for the bytes the trust ledger rules on, and again to parse the branch for{type}/{issue}/{desc}. A.gwm.tomlrewritten between two of those could be approved as one thing and executed as another. All four now answer from one snapshot. Same for the branch a removal deletes and the branch it records: one read of HEAD, so a checkout landing mid-removal can no longer havegwm undorestore a ref that was never deleted while the deleted one stays lost. cycle_sidebar_layoutmoved fromSpacetoz(#484), to make room for the row mark. Space-to-mark is the convention in lazygit, k9s and fzf, so the default was picked on merit rather than on which verb was there first. Both pre-#484 defaults are one[tui.keys]line away (cycle_sidebar_layout = ["Space"],toggle_select = ["z"]), andgwm tui keysprints the resolved set with a per-row source. One upgrade note:zis now a shipped default, so a.gwm.tomlthat binds a chord starting withz(saytop = ["z z"]) is a prefix conflict and is refused at load time, the same way any chord/prefix pair has always been. Rebind that chord, or movecycle_sidebar_layoutelsewhere.
-
An editor or shell configured with arguments now launches.
editor_cmd/shell_cmdin[tui.open], and the$EDITOR/$SHELLthey fall back to, were handed to the spawner whole, so a perfectly ordinaryEDITOR="code --wait"made the system look for an executable literally namedcode --waitandoinmode = "editor"could never open anything. These are shell lines by convention (git, cargo and systemctl all word-split them), and gwm already reads[review]tools and hookrun =lines that way, so they are word-split now, with a quoted program path staying one token and an unbalanced quote falling back to the raw string. Splitting is POSIX and filenames are not, so a value that already names a file is handed over whole and never split:EDITOR=C:\Tools\nvim.exekeeps launching rather than becomingC:Toolsnvim.exeonce the splitter eats the backslashes. Found while reviewing the note editor (#515), which reuses the same handoff and would have shipped with the same hole. The path also reaches the child as anOsStrrather than a lossy string, so a repo path carrying non-UTF-8 bytes no longer opens a different file than the one gwm resolved. -
gwm doctornow reads[hooks.*], not just[[bootstrap.command]]. Two checks walked the bootstrap commands alone, so a config whose commands all live in lifecycle hooks got a report about a file the doctor had barely read: a typo in a hook’swhenpredicate was announced as “nowhen:predicates configured”, and a hook invoking a binary that is not installed produced a clean bill of health right up to the momentgwm createran it and failed. Both now walk the six phases as well, and thewhenfailure names the phase and step it came from (bogus:1 (on hook post_create \install`)). Surfaced by thesymfonypreset, whose commands are all hooks, but it applied to every hooks-based config since the phases landed.LifecycleHooksConfig::all_steps()enumerates the phases through an exhaustive destructuring, so adding a seventh phase without teaching the consumers about it is now a compile error rather than a silent blind spot. The PATH probe also honours each step'swhenpredicate now, on bootstrap commands as well, which it never did: thenodepreset shipsbun installundercmd_exists:bunandnpm ciunder!cmd_exists:bun, so probing both regardless warned about whichever one the predicate had switched off, and a Warning takesgwm doctorto exit code 1. The predicate is evaluated against the main checkout, the same approximation the.envrcprobe already made; an unrecognised keyword still evaluates totrue, matching the step running anyway at bootstrap time. Only two predicate shapes are evaluated, because a.gwm.tomlnever went through the trust gate:cmd_exists:on a bare binary name, which is a$PATHlookup on the same set the probe reports, andfile_exists:on a single repo-root component that is not itself a symlink, which is astaton something the config's own author committed. Everything else is a channel out of the repo for a file nobody vetted, and one declined atom leaves the whole expression unevaluated:glob_exists:picks its own root and walks it, a multi-componentfile_exists:escapes through a committed symlink (outside/etc/passwdwithoutside -> /) in a way no spelling check catches,env_set:/env_eq:read the process environment and report the answer through which binaries got probed, and acmd_exists:argument with a path separator isfile_exists:under another name. Declining costs nothing, the step simply stays probed, which is what the check did before it evaluated anything. A step whose binary cannot be resolved statically is left alone for the same reason, from the other side:lifecycle::run_stepexpands{path}/{repo}inrunbefore spawning, so a hook reading{path}/scripts/setupwas probed as that literal string and always came back missing, and a step that sets its ownPATHinenvresolves against that rather than against the doctor's ambient one. Same for a script that opens on a shell word: arunis handed whole tosh -c, socd sub && ./setup.shorif [ -f composer.json ]; then …used to be probed ascdandif`. That one bit hooks harder than bootstrap commands, since a hook is a script far more often.
-
A comparison page against lazyworktree and gwq (#422).
/comparison, in English and French. The issue’s premise did not survive the re-read it asked for: it was filed when agent sessions, container execution, GitLab and per-worktree notes were lazyworktree’s lead, and gwm shipped all four in the meantime, so the page reports those as parity rather than as a gap. Everything in it is measured rather than recalled (2026-08-12, GitHub API plus both READMEs andgo.modmanifests), which turned up one difference worth stating precisely: neither competitor carries a git binding in its manifest, so “worktree operations run on vendored libgit2 instead of thegitCLI” is checkable rather than claimed. The page names where gwm is behind, because the audience for a terminal worktree manager is the audience that goes and checks: lazyworktree’s rich worktree metadata (colour, icon, tags, which gwm deliberately does not have), its per-worktree.wthook files, and seven more months of people finding its corners. -
Retired the em dash across the whole
docs/tree (#516). 1586 occurrences in 78 of the 79 pages, English and French, replaced by whatever connector the dash was standing in for: a colon where it introduced a list or an explanation, a full stop where it joined two independent clauses, a comma or parentheses around an aside. Fenced code blocks are untouched, since they reproduce shell comments and program output. Schema and reference tables used a bare dash as a cell value for two different things, “no default” and “this preset adds nothing here”; those now read_(required)_and_(none)_. 45 headings change shape, so their generated anchors change with them; none of them was the target of an internal link, and the 194 internal anchors in the tree resolve exactly as they did before.