Skip to content
gwmgwmgwmv1.10.0

v1.7.0

  • Inline review comments in the rich PR view (#528). The comments anchored to a diff hunk now render in the I view, under the reviews: one section per thread, showing its anchor (src/tui/app.rs:7-11, marked resolved / outdated when it applies), the diff hunk it hangs from, and the reply chain in order. These are reachable through GraphQL alone (gh pr view --json comments returns the conversation), so they travel on a second request, fired when the view opens rather than alongside every PR fetch: gwm status calls 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 so Enter opens 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 with position data, under a different request and a different shape, deliberately out of scope here.

  • Rich PR / issue view in the TUI (#420). I opens 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. gwm already asked gh for the rollup and threw the per-check detail away; it now asks for body, author, comments, reviews, additions / deletions and 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. Enter opens the selected row’s URL, f re-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 exec profiles (#421). A [exec.profiles.<name>.container] block wraps that profile’s command in docker run / podman run: image is required, runtime is auto-detected (docker first, then podman) and extra_args is spliced in before the image. The block rides a profile only: an inline gwm 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 .git is 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 makes git 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 declared safe.directory through GIT_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 as dubious ownership, undoing the mount. gwm exec allocates 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 -t so 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 a docker run client leaves the container running and a long command would keep writing to the worktree after the overlay closed; --name in extra_args is refused, since a runtime honours the last one and the teardown would then remove something else. selinux_relabel = true suffixes gwm’s own mounts with :z for an SELinux-enforcing host, which extra_args cannot 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 .git file 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). Space marks the highlighted worktree, d then deletes every marked row in one batch; with nothing marked it stays the single-row delete it has always been. Only d reads the mark set, so the worktrees footer carries the count (3 of 12 · 2 marked) rather than letting a live selection go invisible under b / 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 manual f refresh 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, with D arming 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::remove prunes 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 remove takes several patterns (#484). gwm remove a b c removes the batch in one command and --dry-run prints 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 what gwm list --format json | ... | xargs -n1 gwm remove could 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.

  • symfony config preset (#392). A seventh gwm init --preset, next to laravel on the composer side but built on Symfony’s own dotenv convention, which is the mirror image of Laravel’s: .env is committed and holds the neutral defaults, .env.local is gitignored and holds the secrets. So the preset copies .env.local and .env.test.local rather than .env, and the no-aws-rds guard seeds from the committed .env instead of an .env.example. var/ joins vendor/ 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 install and direnv allow . run on the same when predicates 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. N now 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+e inside it still hands the same file over (editor_cmd, then $EDITOR, then vi, the handoff o uses in mode = "editor") and reloads what that editor wrote. Esc writes 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 / End and PageUp / PageDown are all input and none of them are rebindable: only close and open_editor live 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 survive gwm 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=json list rows carry an additive note field 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; and gwm doctor reports a note whose branch is gone, rather than gwm clean, whose safety property is that --yes only removes directories git already ignores. Two branch names a volume folds together (which git pack-refs lets coexist) share one file, so N refuses 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 folds feat/é onto feat/É or an NFC name onto its NFD twin. Presence means “non-blank”, not “the file exists”, because vi over an empty buffer writes one byte. N also sits in the worktrees which-key, between agents and review, since the footer is the only place a verb is discoverable without opening ? first. One upgrade note, the same one z carried in #484: N is now a shipped default, so a .gwm.toml binding a chord starting with N (say top = ["N x"]) is a prefix conflict and is refused at load time. Rebind that chord, or move edit_note elsewhere.

  • The TUI delete runs the remove hooks and records the undo journal (#521). d called worktree::remove directly, so [hooks.pre_remove] / [hooks.post_remove] never ran and nothing reached gwm 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, so gwm undo puts back what d removed, exactly as it does for gwm remove. undo stays per-worktree: a batch of ten appends ten entries and pops one at a time. Two consequences worth knowing before you upgrade. A pre_remove that 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 all post_create runs 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 remove wrote 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 in gwm history as something gwm undo would replay. That is not a cosmetic leftover: gwm undo saves 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 until history.toml was 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 OID gwm undo needs.
  • The delete reads .gwm.toml once (#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.toml rewritten 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 have gwm undo restore a ref that was never deleted while the deleted one stays lost.
  • cycle_sidebar_layout moved from Space to z (#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"]), and gwm tui keys prints the resolved set with a per-row source. One upgrade note: z is now a shipped default, so a .gwm.toml that binds a chord starting with z (say top = ["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 move cycle_sidebar_layout elsewhere.
  • An editor or shell configured with arguments now launches. editor_cmd / shell_cmd in [tui.open], and the $EDITOR / $SHELL they fall back to, were handed to the spawner whole, so a perfectly ordinary EDITOR="code --wait" made the system look for an executable literally named code --wait and o in mode = "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 hook run = 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.exe keeps launching rather than becoming C:Toolsnvim.exe once 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 an OsStr rather 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 doctor now 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’s when predicate was announced as “no when: predicates configured”, and a hook invoking a binary that is not installed produced a clean bill of health right up to the moment gwm create ran it and failed. Both now walk the six phases as well, and the when failure names the phase and step it came from (bogus:1 (on hook post_create \install`)). Surfaced by the symfonypreset, 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 takes gwm 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, so cd 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 and go.mod manifests), 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 the git CLI” 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 .wt hook 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.