From 97773508a02d4166b317c8ae1a8e06bbc840f91b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:13:12 +0000 Subject: [PATCH 1/5] feat!: remove 'gra fetch' and 'gra switch' Neither command earned its keep. 'gra ls --fetch' already refreshes every repository, so 'gra fetch' was that minus the listing; and a worktree that should hold other work is what 'gra work' and plain 'git switch' are for. With 'gra switch' gone, the branch picker always offers the detached entry and the checked-out guard has no worktree to exempt, so both lose a parameter. The fetch machinery stays for 'gra ls --fetch' and 'gra done'. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nrfsj2RiFkXaetPthLZFZz --- README.md | 84 +++++------------------ gra | 108 +++++------------------------ tests/test_gra_cli.py | 141 ++++---------------------------------- tests/test_gra_helpers.py | 2 +- 4 files changed, 51 insertions(+), 284 deletions(-) diff --git a/README.md b/README.md index 7f4aedd..6abf253 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ gra clone git@github.com:martinus/oans.git # into ~/gra/oans, with a worktree gra cd warmhare # jump into that worktree, from anywhere gra work OA-2345 # another worktree, on that branch gra work warmhare # back to that worktree's tmux window -gra switch main # or reuse the one you are standing in gra done # throw it away once the work is merged gra ls # what do I have, and where is it? ``` @@ -112,15 +111,13 @@ git config --global gra.root ~/develop | ------- | ------------ | | [`gra clone `](#gra-clone) | Clone into `~/gra/` as a bare checkout, with a worktree ready to use | | [`gra work [target]`](#gra-work) | Create a worktree for a branch - or open a worktree's tmux window | -| [`gra switch [branch]`](#gra-switch) | Move the worktree you are standing in to another branch | | [`gra done [name]`](#gra-done) | Remove a worktree once its work is in origin | | [`gra cd [name]`](#gra-cd) | Jump to a worktree, by name or with `fzf` | | [`gra ls [--fetch]`](#gra-ls) | One table of every repository and worktree | -| [`gra fetch`](#gra-fetch) | `git fetch --prune --all` in every repository, in parallel | | [`gra install`](#gra-install) | Install or upgrade `gra` itself | Everything runs from anywhere. The exceptions are the commands that act on -*where you are standing*: `gra switch`, and a bare `gra work` or `gra done`. +*where you are standing*: a bare `gra work` or `gra done`. --- @@ -171,7 +168,7 @@ repository it was forked from: ```text added remote 'upstream' -> git@github.com:upstream/oans.git -run 'gra fetch' for its branches +run 'gra ls --fetch' for its branches ``` A fork is a GitHub concept rather than a Git one, so `gra` asks `gh`, which @@ -264,8 +261,7 @@ with the newest commits first - the branch you are here for is almost always near the top. Branches already checked out in a worktree are not offered, because Git allows a branch in only one worktree at a time. The first entry starts the worktree detached at origin's default branch instead - instantly -usable for looking around, running builds, or letting a later `gra switch` -pick the real work. +usable for looking around or running builds. If the branch you name is already checked out somewhere, `gra` names that worktree instead of leaving you with Git's message: @@ -302,7 +298,7 @@ ERROR: 'OA-7777' matches several branches; name one: ``` Nothing matching means the old behaviour: `gra` offers to create the branch -from origin's default branch. The same resolution applies to `gra switch`. +from origin's default branch. @@ -353,33 +349,6 @@ repository claim a contested name first, and then only that repository shifts. -## `gra switch` - -To reuse the worktree you are standing in for other work instead of creating -a new one: - -```sh -gra switch feature/search # by name, or part of one -gra switch # pick with fzf -``` - -Branch resolution is the same as [`gra work`](#gra-work)'s: existing local -branches are switched to, `origin/` gets a local tracking branch, -missing branches can be created from origin's default branch and pushed, and -part of a name is enough when it matches one branch. Without a branch, an -`fzf` picker offers all branches, newest commits first - minus those already -checked out in a worktree, which a switch could never reach. - -Afterwards the worktree's [tmux window](#tmux-windows) is opened or switched -to. The window is named after the worktree, not the branch, so a switch keeps -the window it already has. - -> [!IMPORTANT] -> `gra switch` refuses when the worktree has uncommitted changes - commit or -> stash first. Switching to a branch checked out elsewhere is refused too, -> naming the worktree that holds it. Run anywhere but inside a worktree it -> fails: there is no checkout there whose branch it could change. - ## `gra done` Run it inside a worktree when the work in it is finished, or name one from @@ -474,26 +443,12 @@ meaning, `BRANCH` is the primary information - the name is just an address. push, `↓1` is one to pull, blank is in sync or has no upstream, and `-` means the question does not apply (detached, or no upstream). It reads local refs only - `gra ls` never goes to the network - so it is as fresh as your last -fetch. `--fetch` is [`gra fetch`](#gra-fetch) followed by `gra ls`. - -## `gra fetch` - -Runs `git fetch --prune --all` in every repository under the gra root, several -at a time: - -```sh -gra fetch -``` - -```text -Fetching 12 repositories -fetched 12 repositories -``` +fetch. -Every remote, not just `origin`: a fork's `upstream` is exactly the remote -that goes stale. Only remote-tracking refs move - no worktree, branch, or -uncommitted change is touched - so it is safe to run at any time, and it is -what makes the `SYNC` column of `gra ls` current. +`--fetch` first runs `git fetch --prune --all` in every repository, several at +a time. Every remote, not just `origin`: a fork's `upstream` is exactly the +remote that goes stale. Only remote-tracking refs move - no worktree, branch, +or uncommitted change is touched - so it is safe to run at any time. A repository that cannot be reached is named with the reason, and the others are still fetched: @@ -552,7 +507,6 @@ Inside tmux, a worktree gets one window, named `/`: | ------- | -------------------------- | | `gra clone`, `gra work ` | opened for the new worktree | | `gra work `, bare `gra work` inside one | opened, or switched to when already open | -| `gra switch` | opened or switched to, keeping its name | | `gra done` | closed | The name is the whole mechanism. There is no config file, nothing to install, @@ -561,8 +515,8 @@ acts on the answer. Two consequences follow. **A window is never duplicated.** A worktree outlives its window, and every command that leaves you in a worktree looks the name up before opening -anything - so `gra switch`, a second `gra work`, and reopening a window you -closed yesterday all land in the same place. +anything - so a second `gra work`, and reopening a window you closed +yesterday, land in the same place. **The window need not be in this session.** `gra` looks at every session, so `gra done snowwolf` closes the window even when it lives in a session you are @@ -636,11 +590,10 @@ The `eval` line installs Bash completion too, so there is nothing else to set up: ```text -gra install clone fetch ls work switch done cd shell +gra install clone ls work done cd shell gra cd warmhare goldfish snowwolf worktree names gra done warmhare goldfish snowwolf --force gra work worktree names, plus branches inside a repository -gra switch branches, inside a repository gra done - --force ``` @@ -659,13 +612,14 @@ every time.
-The layout is designed so that a coding agent can manage branches itself -inside one worktree. A useful convention for a repository's `CLAUDE.md`: +The layout is designed so that a coding agent can manage branches itself. A +useful convention for a repository's `CLAUDE.md`: -* to work on another branch in this worktree: commit or stash, then - `gra switch `, -* `gra switch` also creates missing branches (from origin's default branch, - pushed and tracking) after asking for confirmation. +* to work on another branch: `gra work ` gives it a worktree of its + own, and creates a missing branch (from origin's default branch, pushed and + tracking) after asking for confirmation, +* to reuse the worktree you are in: commit or stash, then plain + `git switch `. For parallel agents, give each its own worktree with `gra work` - one branch can only be checked out in one worktree at a time. diff --git a/gra b/gra index 00f5919..b2b377e 100755 --- a/gra +++ b/gra @@ -15,7 +15,7 @@ branch they have checked out: switch branches freely inside a worktree, or start a new one and remove it when done. """ -__version__ = "6.0.0" +__version__ = "7.0.0" import argparse from concurrent.futures import ThreadPoolExecutor @@ -277,7 +277,7 @@ def add_upstream_remote(bare: Path, url: str) -> None: run_git(["remote", "add", UPSTREAM_REMOTE, parent], cwd=bare) log( f"added remote '{UPSTREAM_REMOTE}' -> {parent}; " - "run 'gra fetch' for its branches", + "run 'gra ls --fetch' for its branches", GREEN, ) @@ -463,7 +463,7 @@ def worktree_branches(bare: Path) -> dict[str, Path]: return {branch: path for path, branch in worktree_entries(bare) if branch} -def refuse_if_branch_is_checked_out(bare: Path, branch: str, current: Path | None = None) -> None: +def refuse_if_branch_is_checked_out(bare: Path, branch: str) -> None: """Point at the worktree already holding a branch, instead of letting Git fail. Git's own message names neither the worktree nor a way forward, and this @@ -471,7 +471,7 @@ def refuse_if_branch_is_checked_out(bare: Path, branch: str, current: Path | Non it again by name is a natural next move. """ holder = worktree_branches(bare).get(branch) - if holder is None or holder == current: + if holder is None: return fail( f"'{branch}' is already checked out in '{holder.name}'; " @@ -757,7 +757,7 @@ def clone( # --------------------------------------------------------------------------- -# fetch command +# Fetch helpers, for 'gra ls --fetch' and 'gra done' # --------------------------------------------------------------------------- @@ -805,13 +805,6 @@ def fetch_repositories(repositories: list[Path]) -> None: log(f"fetched {count} {word}", GREEN) -def fetch_all(root: Path) -> None: - """Fetch every repository under the gra root.""" - repositories = repositories_or_report(root) - if repositories: - fetch_repositories(repositories) - - # --------------------------------------------------------------------------- # ls command # --------------------------------------------------------------------------- @@ -874,7 +867,7 @@ def print_repositories(root: Path, fetch: bool) -> None: # --------------------------------------------------------------------------- -# Picker helpers (cd, work, switch) +# Picker helpers (cd, work) # --------------------------------------------------------------------------- @@ -1304,7 +1297,7 @@ _gra() { local cur=${COMP_WORDS[COMP_CWORD]} cmd=${COMP_WORDS[1]-} container words if (( COMP_CWORD == 1 )); then - COMPREPLY=($(compgen -W 'install clone fetch ls work switch done cd shell' \ + COMPREPLY=($(compgen -W 'install clone ls work done cd shell' \ -- "$cur")) return fi @@ -1332,11 +1325,6 @@ _gra() { container=$(_gra_container) [[ -n $container ]] && words+=$'\n'$(_gra_branches "$container") ;; - switch) - # Outside a repository the command fails anyway, so offer nothing. - container=$(_gra_container) - [[ -n $container ]] && words=$(_gra_branches "$container") - ;; shell) words='bash' ;; @@ -1413,7 +1401,7 @@ def recent_branches(bare: Path) -> list[tuple[str, str]]: BRANCH_PICKER_HEADERS = ["BRANCH", "LAST COMMIT"] -def pick_branch(container: Path, command: str, *, offer_detached: bool) -> str: +def pick_branch(container: Path, command: str) -> str: """Pick a branch with fzf, newest commit first; '' means detached. Branches already checked out are left out: Git allows a branch in one @@ -1423,10 +1411,9 @@ def pick_branch(container: Path, command: str, *, offer_detached: bool) -> str: bare = bare_dir(container) checked_out = worktree_branches(bare) entries: list[tuple[str, list[str]]] = [] - if offer_detached: - ref = remote_default_branch(bare) - if ref: - entries.append(("", [f"{ref} (detached)", ""])) + ref = remote_default_branch(bare) + if ref: + entries.append(("", [f"{ref} (detached)", ""])) entries += [ (name, [name, age]) for name, age in recent_branches(bare) @@ -1529,7 +1516,7 @@ def create_worktree( # --------------------------------------------------------------------------- -# work and switch commands +# work command # --------------------------------------------------------------------------- @@ -1566,43 +1553,10 @@ def work(target: str | None, root: Path) -> None: if target: branch = resolve_branch(bare_dir(container), branch_name_from_input(target)) else: - branch = pick_branch(container, "gra work", offer_detached=True) + branch = pick_branch(container, "gra work") create_worktree(container, root, branch) -def switch(branch_input: str | None) -> None: - """Switch the current worktree to another branch.""" - container, worktree = gra_location() - if worktree is None: - fail( - "gra switch must be run from inside a gra worktree;\n" - "it switches that worktree to another branch - 'gra cd' jumps into one" - ) - bare = bare_dir(container) - - if worktree_status_output(worktree): - fail( - f"'{worktree}' has uncommitted changes; commit or stash them before switching" - ) - - if branch_input: - branch = resolve_branch(bare, branch_name_from_input(branch_input)) - refuse_if_branch_is_checked_out(bare, branch, current=worktree) - else: - # The picker already left out every checked-out branch, so only a - # typed branch can need the refusal. - branch = pick_branch(container, "gra switch", offer_detached=False) - - log(f"Switching '{worktree}' to '{branch}'") - if branch_needs_tracking(bare, branch): - run_git(["switch", "--track", f"origin/{branch}"], cwd=worktree) - else: - run_git(["switch", branch], cwd=worktree) - log(f"'{worktree.name}' is now on '{branch}'", GREEN) - - open_tmux_window(worktree) - - def set_up_worktree(worktree: Path) -> None: """Set an existing worktree up again - which today means its tmux window. @@ -1982,20 +1936,6 @@ def parse_args() -> argparse.Namespace: ) ) - fetch_parser = subparsers.add_parser( - "fetch", - formatter_class=argparse.RawTextHelpFormatter, - help="fetch every repository under the gra root", - description=( - f"Run 'git fetch --prune --all' in every repository under '{root}',\n" - "several at a time. Only remote-tracking refs move: no worktree,\n" - "branch or uncommitted change is touched.\n" - "A repository that cannot be reached is reported and the rest are\n" - "still fetched." - ), - ) - fetch_parser.set_defaults(handler=lambda args: fetch_all(args.root)) - ls_parser = subparsers.add_parser( "ls", formatter_class=argparse.RawTextHelpFormatter, @@ -2004,7 +1944,10 @@ def parse_args() -> argparse.Namespace: f"List repositories and worktrees under '{root}'.\n" "SYNC is ahead/behind the upstream branch as of the last fetch;\n" "it reads local refs only and never goes to the network.\n" - "Use --fetch to refresh every repository first, as 'gra fetch' does." + "Use --fetch to run 'git fetch --prune --all' in every repository\n" + "first, several at a time. Only remote-tracking refs move: no\n" + "worktree, branch or uncommitted change is touched. A repository\n" + "that cannot be reached is reported and the rest are still fetched." ), ) ls_parser.add_argument( @@ -2036,23 +1979,6 @@ def parse_args() -> argparse.Namespace: work_parser.add_argument("target", nargs="?", help="worktree name or branch") work_parser.set_defaults(handler=lambda args: work(args.target, args.root)) - switch_parser = subparsers.add_parser( - "switch", - formatter_class=argparse.RawTextHelpFormatter, - help="switch the current worktree to another branch", - description=( - "Switch the worktree you are standing in to BRANCH, with the same\n" - "branch resolution as 'gra work': part of a name is enough when it\n" - "matches one branch, and a missing branch can be created from\n" - "origin's default branch and pushed.\n" - "Without BRANCH, pick one with fzf, newest commits first.\n" - "Refuses when the worktree has uncommitted changes.\n" - "Opens or switches to the worktree's tmux window afterwards." - ), - ) - switch_parser.add_argument("branch", nargs="?", help="branch to switch to") - switch_parser.set_defaults(handler=lambda args: switch(args.branch)) - done_parser = subparsers.add_parser( "done", formatter_class=argparse.RawTextHelpFormatter, diff --git a/tests/test_gra_cli.py b/tests/test_gra_cli.py index 75bdae8..63ec61c 100644 --- a/tests/test_gra_cli.py +++ b/tests/test_gra_cli.py @@ -734,20 +734,6 @@ def test_ls_survives_a_worktree_whose_directory_is_gone(tmp_path: Path) -> None: assert "× missing" in result.stdout -def test_switch_to_the_branch_it_is_already_on(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - - # The branch is checked out here, so the guard must exempt this worktree. - result = run_cli(["switch", "main"], home, cwd=worktree) - - assert result.returncode == 0, result.stderr - assert git_output(["branch", "--show-current"], cwd=worktree) == "main" - - # --------------------------------------------------------------------------- # tmux windows # --------------------------------------------------------------------------- @@ -893,7 +879,7 @@ def test_work_prefers_a_worktree_name_over_a_branch_of_the_same_name( container = clone_repo(home, source) worktree = work_worktree(home, container, "main") add_feature_branch(source, worktree.name) - run_cli(["fetch"], home) + git(["fetch", "origin"], cwd=container / BARE_DIR) env, log = write_tmux_mock(tmp_path) result = run_cli(["work", worktree.name], home, cwd=container, env_extra=env) @@ -932,22 +918,6 @@ def test_work_inside_a_worktree_says_so_outside_tmux(tmp_path: Path) -> None: assert tmux_calls(log) == [] -def test_switch_opens_the_window_of_the_worktree_it_switched(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - add_feature_branch(source) - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - env, log = write_tmux_mock(tmp_path) - - result = run_cli(["switch", "feature"], home, cwd=worktree, env_extra=env) - - assert result.returncode == 0, result.stderr - # The window is named after the worktree, so switching branches keeps it. - assert f"new-window -n project/{worktree.name} -c {worktree}" in tmux_calls(log) - - def test_done_closes_a_window_it_is_not_running_in(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() @@ -1008,7 +978,7 @@ def test_done_without_a_window_removes_the_worktree_anyway(tmp_path: Path) -> No # --------------------------------------------------------------------------- -# work and switch +# work # --------------------------------------------------------------------------- @@ -1112,88 +1082,6 @@ def test_work_still_offers_to_create_an_unmatched_branch(tmp_path: Path) -> None assert worktree_dirs(container) == [] -def test_switch_resolves_a_ticket_key_too(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - add_feature_branch(source, "mla/OA-2345-thing") - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - - result = run_cli(["switch", "OA-2345"], home, cwd=worktree) - - assert result.returncode == 0, result.stderr - assert git_output(["branch", "--show-current"], cwd=worktree) == "mla/OA-2345-thing" - - -def test_switch_changes_branch_in_place(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - add_feature_branch(source) - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - - result = run_cli(["switch", "feature"], home, cwd=worktree) - - assert result.returncode == 0, result.stderr - assert git_output(["branch", "--show-current"], cwd=worktree) == "feature" - assert (worktree / "README.md").read_text() == "# feature\n" - assert worktree_dirs(container) == [worktree] - - -def test_switch_refuses_dirty_worktree(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - add_feature_branch(source) - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - (worktree / "README.md").write_text("# local edit\n") - - result = run_cli(["switch", "feature"], home, cwd=worktree) - - assert result.returncode == 1 - assert "uncommitted changes" in result.stderr - assert git_output(["branch", "--show-current"], cwd=worktree) == "main" - - -def test_switch_picks_a_branch_with_fzf(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - add_feature_branch(source) - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - fzf_env, fzf_input, _fzf_args = write_fzf_mock(tmp_path) - - result = run_cli(["switch"], home, cwd=worktree, env_extra=fzf_env) - - assert result.returncode == 0, result.stderr - # 'main' is checked out right here and there is no detached entry, so the - # picker offers exactly the one branch a switch could reach. - assert [line.split("\t")[0] for line in fzf_input.read_text().splitlines()] == [ - "feature" - ] - assert git_output(["branch", "--show-current"], cwd=worktree) == "feature" - - -def test_switch_outside_a_worktree_fails_with_guidance(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - container = clone_repo(home, make_repo(tmp_path, "project")) - - outside = run_cli(["switch", "main"], home, cwd=tmp_path) - in_container = run_cli(["switch", "main"], home, cwd=container) - - # The repository folder is not a worktree either: there is no checkout - # there whose branch a switch could change. - for result in (outside, in_container): - assert result.returncode == 1 - assert "gra switch" in result.stderr - assert "gra cd" in result.stderr - - def test_done_removes_merged_worktree_and_branch(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() @@ -1373,7 +1261,7 @@ def test_ls_lists_all_repositories_and_worktrees(tmp_path: Path) -> None: assert "● dirty" in result.stdout -def test_fetch_updates_and_prunes_every_repository(tmp_path: Path) -> None: +def test_ls_fetch_updates_and_prunes_every_repository(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() first = make_repo(tmp_path, "one") @@ -1387,7 +1275,7 @@ def test_fetch_updates_and_prunes_every_repository(tmp_path: Path) -> None: add_feature_branch(source, "later") git(["branch", "-D", "feature"], cwd=source) - result = run_cli(["fetch"], home, cwd=tmp_path) + result = run_cli(["ls", "--fetch"], home, cwd=tmp_path) assert result.returncode == 0, result.stderr assert "fetched 2 repositories" in result.stdout @@ -1401,7 +1289,7 @@ def test_fetch_updates_and_prunes_every_repository(tmp_path: Path) -> None: ) -def test_fetch_updates_every_remote_not_just_origin(tmp_path: Path) -> None: +def test_ls_fetch_updates_every_remote_not_just_origin(tmp_path: Path) -> None: """A fork's 'upstream' is exactly the remote that goes stale.""" home = tmp_path / "home" home.mkdir() @@ -1411,7 +1299,7 @@ def test_fetch_updates_every_remote_not_just_origin(tmp_path: Path) -> None: git(["remote", "add", "upstream", str(parent)], cwd=bare) add_feature_branch(parent) - result = run_cli(["fetch"], home, cwd=tmp_path) + result = run_cli(["ls", "--fetch"], home, cwd=tmp_path) assert result.returncode == 0, result.stderr assert not git_fails( @@ -1419,7 +1307,7 @@ def test_fetch_updates_every_remote_not_just_origin(tmp_path: Path) -> None: ) -def test_fetch_reports_an_unreachable_repository_and_fetches_the_rest( +def test_ls_fetch_reports_an_unreachable_repository_and_fetches_the_rest( tmp_path: Path, ) -> None: home = tmp_path / "home" @@ -1430,7 +1318,7 @@ def test_fetch_reports_an_unreachable_repository_and_fetches_the_rest( git(["remote", "set-url", "origin", str(tmp_path / "gone")], cwd=broken_bare) add_feature_branch(working) - result = run_cli(["fetch"], home, cwd=tmp_path) + result = run_cli(["ls", "--fetch"], home, cwd=tmp_path) # One repository nobody can reach must not cost the others their fetch. assert result.returncode == 0, result.stderr @@ -1441,11 +1329,11 @@ def test_fetch_reports_an_unreachable_repository_and_fetches_the_rest( ) -def test_fetch_without_repositories_says_so(tmp_path: Path) -> None: +def test_ls_fetch_without_repositories_says_so(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() - result = run_cli(["fetch"], home, cwd=tmp_path) + result = run_cli(["ls", "--fetch"], home, cwd=tmp_path) assert result.returncode == 0, result.stderr assert "No repositories found." in result.stdout @@ -1562,7 +1450,7 @@ def test_completion_offers_the_subcommands(tmp_path: Path) -> None: home.mkdir() assert "work" in complete(home, ["gra", ""], tmp_path) - assert complete(home, ["gra", "f"], tmp_path) == ["fetch"] + assert complete(home, ["gra", "w"], tmp_path) == ["work"] def test_completion_offers_every_subcommand(tmp_path: Path) -> None: @@ -1608,10 +1496,9 @@ def test_completion_offers_branches_inside_a_repository(tmp_path: Path) -> None: add_feature_branch(source, "feature/search") container = clone_repo(home, source) - for cmd in ("work", "switch"): - words = complete(home, ["gra", cmd, ""], container) - assert "main" in words - assert "feature/search" in words + words = complete(home, ["gra", "work", ""], container) + assert "main" in words + assert "feature/search" in words def test_completion_offers_worktree_names_for_work_outside_a_repository( diff --git a/tests/test_gra_helpers.py b/tests/test_gra_helpers.py index 8099536..ddbcd1d 100644 --- a/tests/test_gra_helpers.py +++ b/tests/test_gra_helpers.py @@ -66,7 +66,7 @@ def test_words_are_short_unique_and_safe() -> None: # Both words run together, and nothing gra reserves is that long - which is # what keeps a name from ever being read as a command or a repository path. assert all(len(name) == 8 for name in names) - reserved = {"install", "clone", "fetch", "ls", "work", "switch", "done", "cd", "shell"} + reserved = {"install", "clone", "ls", "work", "done", "cd", "shell"} assert not (reserved | {"main", "bare", "root", gra.BARE_DIR}) & names # A doubled letter where the words meet is fine (snowwolf); three in a row # (talllion, pureeels) is not readable, so one of the two words has to go. From efffd6d28ba47d14283827e2a38272cd2f7fbcbd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:17:45 +0000 Subject: [PATCH 2/5] feat!: make 'gra cd' name-only, dropping its fzf picker 'gra ls' shows the names and Tab completes them, so the picker was a second way to do what the shell already does. NAME is now required, and 'gra cd' just resolves it to the worktree's path. The worktree row type and its picker plumbing existed only for that listing, so resolving a name collapses to a filesystem probe that returns the path. fzf stays for the branch picker of 'gra work'. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nrfsj2RiFkXaetPthLZFZz --- README.md | 17 ++++----- gra | 84 ++++++++----------------------------------- tests/test_gra_cli.py | 23 +++++------- 3 files changed, 32 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 6abf253..55a5238 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ your shell afterwards. > [!NOTE] > Needs **Git** and **Python 3.10+**. [`fzf`](https://github.com/junegunn/fzf) -> powers the worktree and branch pickers, [`gh`](https://cli.github.com/) lets +> powers the branch picker of `gra work`, [`gh`](https://cli.github.com/) lets > `gra` spot that a clone is a fork, and `tmux` - when you work inside it - > gets a window per worktree. @@ -87,8 +87,7 @@ When there is no `~/.bashrc` (`gra shell` only speaks bash) nothing is written and the lines to add are printed instead. That one `eval` is what makes `gra cd` change directories, `gra done` leave -the removed directory, and `` complete worktrees, repositories and -branches. +the removed directory, and `` complete worktree names and branches. @@ -112,7 +111,7 @@ git config --global gra.root ~/develop | [`gra clone `](#gra-clone) | Clone into `~/gra/` as a bare checkout, with a worktree ready to use | | [`gra work [target]`](#gra-work) | Create a worktree for a branch - or open a worktree's tmux window | | [`gra done [name]`](#gra-done) | Remove a worktree once its work is in origin | -| [`gra cd [name]`](#gra-cd) | Jump to a worktree, by name or with `fzf` | +| [`gra cd `](#gra-cd) | Jump to a worktree by name | | [`gra ls [--fetch]`](#gra-ls) | One table of every repository and worktree | | [`gra install`](#gra-install) | Install or upgrade `gra` itself | @@ -405,12 +404,14 @@ scripts, where an unanswered question counts as no. ## `gra cd` ```sh -gra cd # pick with fzf -gra cd snowwolf # jump straight to the worktree named snowwolf +gra cd snowwolf # jump to the worktree named snowwolf ``` -The command prints the selected path; the shell integration is what turns that -into an actual `cd`. `gra install` adds it for you, or add it yourself: +Because a name identifies one worktree on the whole machine, that is all `gra` +needs - and `` completes the names, so `gra ls` followed by `gra cd` and +Tab is the whole navigation. The command prints the worktree's path; the shell +integration is what turns that into an actual `cd`. `gra install` adds it for +you, or add it yourself: ```sh eval "$(gra shell bash)" diff --git a/gra b/gra index b2b377e..cd904c3 100755 --- a/gra +++ b/gra @@ -15,7 +15,7 @@ branch they have checked out: switch branches freely inside a worktree, or start a new one and remove it when done. """ -__version__ = "7.0.0" +__version__ = "8.0.0" import argparse from concurrent.futures import ThreadPoolExecutor @@ -867,7 +867,7 @@ def print_repositories(root: Path, fetch: bool) -> None: # --------------------------------------------------------------------------- -# Picker helpers (cd, work) +# Worktree name lookup, and the fzf table behind the branch picker # --------------------------------------------------------------------------- @@ -876,10 +876,9 @@ def fzf_table( ) -> str: """Select from (key, columns) entries with fzf and return the chosen key. - Owns the whole table convention - aligned columns behind a hidden key - - so every picker lays out and selects the same way. A cancelled fzf exits - this process with fzf's own status, so callers only ever see a selection - that was actually made. + Owns the whole table convention - aligned columns behind a hidden key. + A cancelled fzf exits this process with fzf's own status, so callers + only ever see a selection that was actually made. """ if not shutil.which("fzf"): fail(f"fzf is required for '{command}'") @@ -916,47 +915,6 @@ def fzf_table( return selected.split("\t", 1)[0] -class WorktreeRow(NamedTuple): - """One worktree in pickers and machine-readable listings.""" - - path: str - repo: str - word: str - ref: str - - def values(self) -> list[str]: - return [self.repo, self.word, self.ref] - - -def local_worktree_picker_rows(root: Path) -> list[WorktreeRow]: - """Return local worktree picker rows under the gra root.""" - entries = [ - (container.name, path) - for container in containers(root) - for path in worktree_paths(bare_dir(container)) - if path.is_dir() - ] - refs = parallel_map(lambda entry: worktree_ref(entry[1]), entries) - return [ - WorktreeRow(str(path), repo, path.name, ref) - for (repo, path), ref in zip(entries, refs) - ] - - -PICKER_HEADERS = ["REPOSITORY", "WORKTREE", "BRANCH"] - - -def select_worktree_row_from_rows(rows: list[WorktreeRow], source: str) -> WorktreeRow: - """Select a worktree with fzf and return its picker row.""" - if not rows: - fail(f"no worktrees found {source}") - target = fzf_table("gra cd", PICKER_HEADERS, [(row.path, row.values()) for row in rows]) - for row in rows: - if row.path == target: - return row - fail(f"invalid worktree selection '{target}'") - - def find_worktree_path(root: Path, name: str) -> Path | None: """The worktree directory called name, or None when there is no such name. @@ -973,26 +931,12 @@ def find_worktree_path(root: Path, name: str) -> Path | None: return None -def resolve_worktree_name(root: Path, name: str) -> WorktreeRow: - """Resolve a worktree name like 'snowwolf' to its row.""" +def resolve_worktree_name(root: Path, name: str) -> Path: + """Resolve a worktree name like 'snowwolf' to its directory.""" path = find_worktree_path(root, name) if path is None: fail(f"no worktree named '{name}' under '{root}'") - return WorktreeRow(str(path), path.parent.name, name, worktree_ref(path)) - - -def pick_worktree_row(root: Path, name: str | None = None) -> WorktreeRow: - """Resolve a worktree by name, or select one with fzf.""" - if name: - return resolve_worktree_name(root, name) - return select_worktree_row_from_rows( - local_worktree_picker_rows(root), f"under '{root}'" - ) - - -def print_cd_target(root: Path, name: str | None) -> None: - """Print the path of a worktree chosen by name or with fzf.""" - print(pick_worktree_row(root, name).path) + return path # --------------------------------------------------------------------------- @@ -1808,7 +1752,7 @@ def confirm_removal( def done(name: str | None, root: Path, force: bool) -> None: """Remove a worktree once its work is safely in origin.""" if name: - worktree = Path(resolve_worktree_name(root, name).path) + worktree = resolve_worktree_name(root, name) container = worktree.parent else: container, worktree = current_gra_worktree("done") @@ -2006,14 +1950,16 @@ def parse_args() -> argparse.Namespace: cd_parser = subparsers.add_parser( "cd", formatter_class=argparse.RawTextHelpFormatter, - help="print a worktree path, by name or with fzf", + help="print a worktree path, by name", description=( - "Print the path of a worktree, chosen by NAME or with fzf.\n" + "Print the path of the worktree called NAME; Tab completes the names.\n" "Use 'eval \"$(gra shell bash)\"' to make 'gra cd' change directories." ), ) - cd_parser.add_argument("name", nargs="?", help="worktree name, for example 'snowwolf'") - cd_parser.set_defaults(handler=lambda args: print_cd_target(args.root, args.name)) + cd_parser.add_argument("name", help="worktree name, for example 'snowwolf'") + cd_parser.set_defaults( + handler=lambda args: print(resolve_worktree_name(args.root, args.name)) + ) shell_parser = subparsers.add_parser( "shell", diff --git a/tests/test_gra_cli.py b/tests/test_gra_cli.py index 63ec61c..adea934 100644 --- a/tests/test_gra_cli.py +++ b/tests/test_gra_cli.py @@ -1385,25 +1385,18 @@ def test_cd_with_unknown_name_fails(tmp_path: Path) -> None: assert "no worktree named 'zzzz'" in result.stderr -def test_cd_prints_selected_worktree_path_from_fzf(tmp_path: Path) -> None: +def test_cd_requires_a_name(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() - source = make_repo(tmp_path, "project") - container = clone_repo(home, source) - worktree = work_worktree(home, container, "main") - - fzf_env, fzf_input, fzf_args = write_fzf_mock(tmp_path) + container = clone_repo(home, make_repo(tmp_path, "project")) + work_worktree(home, container, "main") - result = run_cli(["cd"], home, cwd=tmp_path, env_extra=fzf_env) + result = run_cli(["cd"], home, cwd=tmp_path) - assert result.returncode == 0, result.stderr - assert result.stdout == f"{worktree}\n" - args = fzf_args.read_text().splitlines() - assert "--tiebreak=begin,index" in args - assert "--with-nth=2" in args - assert fzf_input.read_text().splitlines() == [ - f"{worktree}\tproject {worktree.name} main", - ] + # There is no picker any more: Tab completion offers the names, and a + # bare 'gra cd' is a usage error rather than a prompt. + assert result.returncode == 2 + assert "name" in result.stderr def complete(home: Path, words: list[str], cwd: Path) -> list[str]: From 92a29ccef5f00aad296f771e57a89577d266fae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:25:20 +0000 Subject: [PATCH 3/5] feat: add 'gra each' to run a command in every repository 'gra each git fetch --all --tags' runs that once per repository, in its bare checkout, so Git commands act on the repository itself. Repositories run one at a time with the command's output streaming to the terminal; failures do not stop the rest and are listed at the end, with a non-zero exit. Tab completes 'each' and the command name after it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nrfsj2RiFkXaetPthLZFZz --- README.md | 35 +++++++++++++++++- gra | 64 +++++++++++++++++++++++++++++++-- tests/test_gra_cli.py | 75 +++++++++++++++++++++++++++++++++++++++ tests/test_gra_helpers.py | 2 +- 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 55a5238..41f918b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ git config --global gra.root ~/develop | [`gra done [name]`](#gra-done) | Remove a worktree once its work is in origin | | [`gra cd `](#gra-cd) | Jump to a worktree by name | | [`gra ls [--fetch]`](#gra-ls) | One table of every repository and worktree | +| [`gra each `](#gra-each) | Run a command once in every repository | | [`gra install`](#gra-install) | Install or upgrade `gra` itself | Everything runs from anywhere. The exceptions are the commands that act on @@ -460,6 +461,37 @@ oans: fatal: could not read from remote repository. fetched 11 of 12 repositories ``` +## `gra each` + +Runs a command of your own once in every repository under the gra root: + +```sh +gra each git fetch --all --tags +gra each git gc +gra each du -sh . +``` + +Everything after `each` is the command - `gra` passes it through untouched, +flags and all. It runs in each repository's `.bare` directory, so Git +commands act on the repository itself, not on one worktree. The repositories +run one at a time, each announced by name, with the command's output shown +as it runs: + +```text +gra: git fetch --all --tags +Fetching origin +oans: git fetch --all --tags +Fetching origin +Fetching upstream +``` + +A repository where the command fails does not stop the others. The failed +ones are listed at the end, and `gra` exits with an error: + +```text +ERROR: failed in: oans +``` + ## `gra install` ```sh @@ -591,10 +623,11 @@ The `eval` line installs Bash completion too, so there is nothing else to set up: ```text -gra install clone ls work done cd shell +gra install clone ls each work done cd shell gra cd warmhare goldfish snowwolf worktree names gra done warmhare goldfish snowwolf --force gra work worktree names, plus branches inside a repository +gra each commands on your PATH gra done - --force ``` diff --git a/gra b/gra index cd904c3..b0c0406 100755 --- a/gra +++ b/gra @@ -15,7 +15,7 @@ branch they have checked out: switch branches freely inside a worktree, or start a new one and remove it when done. """ -__version__ = "8.0.0" +__version__ = "8.1.0" import argparse from concurrent.futures import ThreadPoolExecutor @@ -805,6 +805,37 @@ def fetch_repositories(repositories: list[Path]) -> None: log(f"fetched {count} {word}", GREEN) +# --------------------------------------------------------------------------- +# each command +# --------------------------------------------------------------------------- + + +def run_in_each_repository(root: Path, command: list[str]) -> None: + """Run a command in every repository's bare checkout, one at a time. + + One at a time on purpose: the command's output goes straight to the + terminal, and interleaving the repositories would make it unreadable. + A repository where the command fails does not stop the others; the + failures are reported at the end. + """ + if not command: + fail( + "gra each needs a command to run, for example:\n" + " gra each git fetch --all --tags" + ) + failed: list[str] = [] + for container in repositories_or_report(root): + log(f"{container.name}: {shlex.join(command)}") + try: + result = subprocess.run(command, cwd=bare_dir(container), check=False) + except OSError as error: + fail(f"cannot run '{command[0]}': {error}") + if result.returncode != 0: + failed.append(container.name) + if failed: + fail(f"failed in: {', '.join(failed)}") + + # --------------------------------------------------------------------------- # ls command # --------------------------------------------------------------------------- @@ -1241,7 +1272,7 @@ _gra() { local cur=${COMP_WORDS[COMP_CWORD]} cmd=${COMP_WORDS[1]-} container words if (( COMP_CWORD == 1 )); then - COMPREPLY=($(compgen -W 'install clone ls work done cd shell' \ + COMPREPLY=($(compgen -W 'install clone ls each work done cd shell' \ -- "$cur")) return fi @@ -1269,6 +1300,11 @@ _gra() { container=$(_gra_container) [[ -n $container ]] && words+=$'\n'$(_gra_branches "$container") ;; + each) + # Everything after 'each' is a command of your own: complete the + # command name, and leave its arguments to it. + (( COMP_CWORD == 2 )) && words=$(compgen -c -- "$cur") + ;; shell) words='bash' ;; @@ -1899,6 +1935,30 @@ def parse_args() -> argparse.Namespace: ) ls_parser.set_defaults(handler=lambda args: print_repositories(args.root, args.fetch)) + each_parser = subparsers.add_parser( + "each", + formatter_class=argparse.RawTextHelpFormatter, + help="run a command in every repository", + description=( + f"Run COMMAND once in every repository under '{root}', one\n" + f"repository at a time, in its '{BARE_DIR}' directory - so Git\n" + "commands act on the repository itself, for example:\n" + " gra each git fetch --all --tags\n" + "Everything after 'each' is passed through untouched, flags\n" + "and all. A failure does not stop the other repositories; the\n" + "failed ones are listed at the end and gra exits with an error." + ), + ) + each_parser.add_argument( + "command", + nargs=argparse.REMAINDER, + metavar="command", + help="the command and its arguments", + ) + each_parser.set_defaults( + handler=lambda args: run_in_each_repository(args.root, args.command) + ) + work_parser = subparsers.add_parser( "work", formatter_class=argparse.RawTextHelpFormatter, diff --git a/tests/test_gra_cli.py b/tests/test_gra_cli.py index adea934..e9dd3d3 100644 --- a/tests/test_gra_cli.py +++ b/tests/test_gra_cli.py @@ -1360,6 +1360,72 @@ def test_ls_fetch_refreshes_the_sync_column(tmp_path: Path) -> None: assert "↓1" in fetched.stdout +def test_each_runs_the_command_in_every_repository(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + first = clone_repo(home, make_repo(tmp_path, "one")) + second = clone_repo(home, make_repo(tmp_path, "two")) + + result = run_cli(["each", "touch", "marker"], home, cwd=tmp_path) + + assert result.returncode == 0, result.stderr + # The command runs in the bare checkout, so Git commands act on the + # repository itself rather than on one worktree. + assert (first / BARE_DIR / "marker").is_file() + assert (second / BARE_DIR / "marker").is_file() + + +def test_each_passes_flags_through_to_the_command(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + clone_repo(home, make_repo(tmp_path, "project")) + + result = run_cli( + ["each", "git", "rev-parse", "--is-bare-repository"], home, cwd=tmp_path + ) + + assert result.returncode == 0, result.stderr + assert "true" in result.stdout + + +def test_each_reports_failures_and_keeps_going(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + clone_repo(home, make_repo(tmp_path, "broken")) + working = clone_repo(home, make_repo(tmp_path, "working")) + (working / BARE_DIR / "marker").write_text("") + + result = run_cli(["each", "test", "-e", "marker"], home, cwd=tmp_path) + + # One repository where the command fails must not cost the others their + # run, but it must show up in the exit status and be named. + assert result.returncode == 1 + assert "failed in: broken" in result.stderr + assert "working" not in result.stderr + + +def test_each_without_a_command_fails_with_an_example(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + clone_repo(home, make_repo(tmp_path, "project")) + + result = run_cli(["each"], home, cwd=tmp_path) + + assert result.returncode == 1 + assert "needs a command" in result.stderr + + +def test_each_with_an_unknown_command_says_so(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + clone_repo(home, make_repo(tmp_path, "project")) + + result = run_cli(["each", "no-such-command-zzzz"], home, cwd=tmp_path) + + assert result.returncode == 1 + assert "cannot run 'no-such-command-zzzz'" in result.stderr + + def test_cd_with_name_prints_worktree_path(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() @@ -1494,6 +1560,15 @@ def test_completion_offers_branches_inside_a_repository(tmp_path: Path) -> None: assert "feature/search" in words +def test_completion_offers_command_names_for_each(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + + assert "git" in complete(home, ["gra", "each", "g"], tmp_path) + # The arguments belong to that command, so nothing is offered for them. + assert complete(home, ["gra", "each", "git", ""], tmp_path) == [] + + def test_completion_offers_worktree_names_for_work_outside_a_repository( tmp_path: Path, ) -> None: diff --git a/tests/test_gra_helpers.py b/tests/test_gra_helpers.py index ddbcd1d..345a20b 100644 --- a/tests/test_gra_helpers.py +++ b/tests/test_gra_helpers.py @@ -66,7 +66,7 @@ def test_words_are_short_unique_and_safe() -> None: # Both words run together, and nothing gra reserves is that long - which is # what keeps a name from ever being read as a command or a repository path. assert all(len(name) == 8 for name in names) - reserved = {"install", "clone", "ls", "work", "done", "cd", "shell"} + reserved = {"install", "clone", "ls", "each", "work", "done", "cd", "shell"} assert not (reserved | {"main", "bare", "root", gra.BARE_DIR}) & names # A doubled letter where the words meet is fine (snowwolf); three in a row # (talllion, pureeels) is not readable, so one of the two words has to go. From 1fb50ad453972d18bb83621f75dc8f2df5c21b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:31:24 +0000 Subject: [PATCH 4/5] feat: add 'gra each --wt' to run in every worktree 'gra each --wt git merge --ff-only' runs the command once per worktree instead of once per repository, labelled / the way tmux windows are. Repositories without any worktree say so instead of silently doing nothing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nrfsj2RiFkXaetPthLZFZz --- README.md | 21 ++++++++++---- gra | 66 +++++++++++++++++++++++++++++++------------ tests/test_gra_cli.py | 47 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 41f918b..abea3a4 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ git config --global gra.root ~/develop | [`gra done [name]`](#gra-done) | Remove a worktree once its work is in origin | | [`gra cd `](#gra-cd) | Jump to a worktree by name | | [`gra ls [--fetch]`](#gra-ls) | One table of every repository and worktree | -| [`gra each `](#gra-each) | Run a command once in every repository | +| [`gra each [--wt] `](#gra-each) | Run a command once in every repository or worktree | | [`gra install`](#gra-install) | Install or upgrade `gra` itself | Everything runs from anywhere. The exceptions are the commands that act on @@ -471,11 +471,11 @@ gra each git gc gra each du -sh . ``` -Everything after `each` is the command - `gra` passes it through untouched, -flags and all. It runs in each repository's `.bare` directory, so Git -commands act on the repository itself, not on one worktree. The repositories -run one at a time, each announced by name, with the command's output shown -as it runs: +Everything after `each` - apart from its own `--wt` - is the command; `gra` +passes it through untouched, flags and all. It runs in each repository's +`.bare` directory, so Git commands act on the repository itself, not on one +worktree. The repositories run one at a time, each announced by name, with +the command's output shown as it runs: ```text gra: git fetch --all --tags @@ -485,6 +485,14 @@ Fetching origin Fetching upstream ``` +With `--wt` the command runs in every worktree instead, one run per +worktree, labelled `/`: + +```sh +gra each --wt git merge --ff-only # fast-forward every worktree +gra each --wt git status --short +``` + A repository where the command fails does not stop the others. The failed ones are listed at the end, and `gra` exits with an error: @@ -629,6 +637,7 @@ gra done warmhare goldfish snowwolf --force gra work worktree names, plus branches inside a repository gra each commands on your PATH gra done - --force +gra each - --wt ``` What it offers follows the same rule the commands do, so the completion and diff --git a/gra b/gra index b0c0406..7d2b95c 100755 --- a/gra +++ b/gra @@ -15,7 +15,7 @@ branch they have checked out: switch branches freely inside a worktree, or start a new one and remove it when done. """ -__version__ = "8.1.0" +__version__ = "8.2.0" import argparse from concurrent.futures import ThreadPoolExecutor @@ -810,13 +810,33 @@ def fetch_repositories(repositories: list[Path]) -> None: # --------------------------------------------------------------------------- -def run_in_each_repository(root: Path, command: list[str]) -> None: - """Run a command in every repository's bare checkout, one at a time. +def each_targets(root: Path, worktrees: bool) -> list[tuple[str, Path]]: + """The (label, directory) pairs 'gra each' runs in. + + Without --wt one per repository, its bare checkout; with --wt one per + worktree, labelled '/' the way tmux windows are. + """ + repositories = repositories_or_report(root) + if not worktrees: + return [(container.name, bare_dir(container)) for container in repositories] + targets = [ + (f"{container.name}/{path.name}", path) + for container in repositories + for path in worktree_paths(bare_dir(container)) + if path.is_dir() + ] + if repositories and not targets: + print("No worktrees found.") + return targets + + +def run_in_each(root: Path, command: list[str], *, worktrees: bool) -> None: + """Run a command in every repository or worktree, one at a time. One at a time on purpose: the command's output goes straight to the - terminal, and interleaving the repositories would make it unreadable. - A repository where the command fails does not stop the others; the - failures are reported at the end. + terminal, and interleaving the runs would make it unreadable. A place + where the command fails does not stop the others; the failures are + reported at the end. """ if not command: fail( @@ -824,14 +844,14 @@ def run_in_each_repository(root: Path, command: list[str]) -> None: " gra each git fetch --all --tags" ) failed: list[str] = [] - for container in repositories_or_report(root): - log(f"{container.name}: {shlex.join(command)}") + for label, directory in each_targets(root, worktrees): + log(f"{label}: {shlex.join(command)}") try: - result = subprocess.run(command, cwd=bare_dir(container), check=False) + result = subprocess.run(command, cwd=directory, check=False) except OSError as error: fail(f"cannot run '{command[0]}': {error}") if result.returncode != 0: - failed.append(container.name) + failed.append(label) if failed: fail(f"failed in: {', '.join(failed)}") @@ -1270,6 +1290,7 @@ _gra_branches() { _gra() { local cur=${COMP_WORDS[COMP_CWORD]} cmd=${COMP_WORDS[1]-} container words + local prev=${COMP_WORDS[COMP_CWORD-1]-} if (( COMP_CWORD == 1 )); then COMPREPLY=($(compgen -W 'install clone ls each work done cd shell' \ @@ -1284,6 +1305,7 @@ _gra() { clone) words='--name --no-work --no-tmux --no-submodules --no-upstream' ;; done) words='--force' ;; ls) words='--fetch' ;; + each) words='--wt' ;; install) words='--no-check' ;; esac COMPREPLY=($(compgen -W "$words" -- "$cur")) @@ -1301,9 +1323,9 @@ _gra() { [[ -n $container ]] && words+=$'\n'$(_gra_branches "$container") ;; each) - # Everything after 'each' is a command of your own: complete the - # command name, and leave its arguments to it. - (( COMP_CWORD == 2 )) && words=$(compgen -c -- "$cur") + # Everything after 'each' (and its own --wt) is a command of your + # own: complete the command name, and leave its arguments to it. + [[ $prev == each || $prev == --wt ]] && words=$(compgen -c -- "$cur") ;; shell) words='bash' @@ -1938,17 +1960,25 @@ def parse_args() -> argparse.Namespace: each_parser = subparsers.add_parser( "each", formatter_class=argparse.RawTextHelpFormatter, - help="run a command in every repository", + help="run a command in every repository or worktree", description=( f"Run COMMAND once in every repository under '{root}', one\n" f"repository at a time, in its '{BARE_DIR}' directory - so Git\n" "commands act on the repository itself, for example:\n" " gra each git fetch --all --tags\n" - "Everything after 'each' is passed through untouched, flags\n" - "and all. A failure does not stop the other repositories; the\n" - "failed ones are listed at the end and gra exits with an error." + "With --wt it runs in every worktree instead:\n" + " gra each --wt git merge --ff-only\n" + "Everything after 'each' (and its own --wt) is passed through\n" + "untouched, flags and all. A failure does not stop the other\n" + "runs; the failed ones are listed at the end and gra exits\n" + "with an error." ), ) + each_parser.add_argument( + "--wt", + action="store_true", + help="run in every worktree instead of every repository", + ) each_parser.add_argument( "command", nargs=argparse.REMAINDER, @@ -1956,7 +1986,7 @@ def parse_args() -> argparse.Namespace: help="the command and its arguments", ) each_parser.set_defaults( - handler=lambda args: run_in_each_repository(args.root, args.command) + handler=lambda args: run_in_each(args.root, args.command, worktrees=args.wt) ) work_parser = subparsers.add_parser( diff --git a/tests/test_gra_cli.py b/tests/test_gra_cli.py index e9dd3d3..c2303d0 100644 --- a/tests/test_gra_cli.py +++ b/tests/test_gra_cli.py @@ -1404,6 +1404,51 @@ def test_each_reports_failures_and_keeps_going(tmp_path: Path) -> None: assert "working" not in result.stderr +def test_each_wt_runs_in_every_worktree(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + first = clone_repo(home, make_repo(tmp_path, "one")) + second = clone_repo(home, make_repo(tmp_path, "two")) + first_worktree = work_worktree(home, first, "main") + second_worktree = work_worktree(home, second, "main") + + result = run_cli(["each", "--wt", "touch", "marker"], home, cwd=tmp_path) + + assert result.returncode == 0, result.stderr + assert (first_worktree / "marker").is_file() + assert (second_worktree / "marker").is_file() + # The worktrees ran, not the repositories. + assert not (first / BARE_DIR / "marker").exists() + assert f"one/{first_worktree.name}:" in result.stdout + + +def test_each_wt_names_the_failing_worktree(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + container = clone_repo(home, make_repo(tmp_path, "project")) + good = work_worktree(home, container, "main") + bad = work_worktree(home, container) + (good / "marker").write_text("") + + result = run_cli(["each", "--wt", "test", "-e", "marker"], home, cwd=tmp_path) + + assert result.returncode == 1 + assert f"failed in: project/{bad.name}" in result.stderr + + +def test_each_wt_without_worktrees_says_so(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + clone_repo(home, make_repo(tmp_path, "project")) + + result = run_cli(["each", "--wt", "touch", "marker"], home, cwd=tmp_path) + + # Doing nothing is correct here, but doing it silently would look like + # the command ran. + assert result.returncode == 0, result.stderr + assert "No worktrees found." in result.stdout + + def test_each_without_a_command_fails_with_an_example(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() @@ -1565,6 +1610,8 @@ def test_completion_offers_command_names_for_each(tmp_path: Path) -> None: home.mkdir() assert "git" in complete(home, ["gra", "each", "g"], tmp_path) + assert "git" in complete(home, ["gra", "each", "--wt", "g"], tmp_path) + assert complete(home, ["gra", "each", "-"], tmp_path) == ["--wt"] # The arguments belong to that command, so nothing is offered for them. assert complete(home, ["gra", "each", "git", ""], tmp_path) == [] From b1bd9066a31dec9f584eb46b8cd5bf353f23efb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:38:32 +0000 Subject: [PATCH 5/5] ci: publish a GitHub release when a version tag is pushed The release notes are the annotated tag's message, so cutting a release is one tag push and nothing to click. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nrfsj2RiFkXaetPthLZFZz --- .github/workflows/release.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..04db15a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: release + +# Pushing an annotated tag like v8.2.0 publishes a GitHub release for it, +# with the tag's own message as the release notes. Write the notes into the +# tag (git tag -a --cleanup=verbatim -F notes.md vX.Y.Z) and push it. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - run: gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --notes-from-tag --title "gra ${TAG#v}" + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }}