From 02d61e7b3485181898eff565eb4dba442821d15c Mon Sep 17 00:00:00 2001 From: Martin Leitner-Ankerl Date: Wed, 29 Jul 2026 11:40:18 +0200 Subject: [PATCH] feat!: add 'gra fetch', remove 'gra clean' and 'gra migrate' Fetching was hidden inside 'gra clean', so the one command that went to the network was the one about removing worktrees, while 'gra ls' - the command whose SYNC column the fetch is for - stayed offline with no way to refresh it. 'gra fetch' runs 'git fetch --prune --all' in every repository under the gra root, several at a time, and 'gra ls --fetch' does that first and then prints the table. Every remote rather than just origin: a fork's 'upstream' is exactly the remote that goes stale. A repository that cannot be reached is named with git's reason and the others are still fetched. 'gra clean' is gone: 'gra done' removes a worktree with the same merged and patch-equivalent checks, one at a time and where you are standing. That takes the '.grakeep' keep marker with it - nothing reads it any more, so it is no longer written to the local exclude file. 'gra migrate' is gone too. It renamed worktrees named by gra 1.x-3.x, which have all been renamed by now. Co-Authored-By: Claude Opus 5 --- README.md | 122 +++-------- gra | 449 ++++++++++---------------------------- tests/test_gra_cli.py | 184 +++++----------- tests/test_gra_helpers.py | 58 ++--- 4 files changed, 220 insertions(+), 593 deletions(-) diff --git a/README.md b/README.md index afe50d3..f3278cd 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ repository it was forked from: ```text added remote 'upstream' -> git@github.com:upstream/oans.git -run 'git fetch upstream' to get its branches +run 'gra fetch' for its branches ``` A fork is a GitHub concept rather than a Git one, so `gra` asks `gh`, which @@ -164,9 +164,8 @@ The bare checkout is set up to behave like a normal clone: remote-tracking refs, and never mirrors them into local branches, * `origin/HEAD` points at origin's default branch, * reflogs are enabled (bare repositories disable them by default), -* `.claude/worktrees/` and `.grakeep` are added to the shared local Git - exclude file so tool-managed paths and keep markers do not show up as - untracked files in any worktree. +* `.claude/worktrees/` is added to the shared local Git exclude file so + tool-managed paths do not show up as untracked files in any worktree. ## work - create a new worktree, or set one up again @@ -280,10 +279,6 @@ another repository is skipped the same way, and `gra done` frees a name for the next `gra work` to reclaim. Machines only disagree when one of them let another repository claim a contested name first, and then only that repository shifts. -Worktrees created before `gra` 4.0 carry a single four-letter word instead; -[`gra migrate`](#migrate---rename-worktrees-to-the-current-naming-scheme) -renames them. - ### Hooks `gra` runs two scripts from the repository container, next to `.bare`: @@ -291,7 +286,7 @@ renames them. | Hook | When | Working directory | | --------- | --------------------------------------------- | ----------------- | | `work.sh` | whenever `gra` leaves you in a worktree | that worktree | -| `done.sh` | before `gra done` or `gra clean` removes one | the container | +| `done.sh` | before `gra done` removes one | the container | `work.sh` runs once per worktree only in the simplest case. It also runs after `gra switch` moves a worktree to another branch, and again whenever `gra work` @@ -437,111 +432,50 @@ information; the name is just an address. push, `↓1` is one to pull, and blank is in sync or has no upstream. It reads local refs only - `gra ls` never goes to the network - so it is as fresh as your last fetch. A `-` means the question does not apply: a detached worktree, -or a branch with no upstream. To refresh it, fetch (`gra clean` does, for every -repository) and run `gra ls` again. - -## hooks - write missing hooks +or a branch with no upstream. Pass `--fetch` to refresh every repository +first, which is `gra fetch` followed by `gra ls`: ```sh -gra hooks +gra ls --fetch ``` -Writes `work.sh` and `done.sh` into every repository under the gra root that -is missing them, and leaves existing ones alone - so it cannot overwrite your -edits and is safe to re-run. +## fetch - fetch every repository -## clean - report or remove clean merged worktrees - -Run `gra clean` from anywhere to classify every worktree under the configured -gra root. It prints one table like `gra ls`, with `VERDICT` and `REASON` -columns added: +Run `gra fetch` from anywhere to run `git fetch --prune --all` in every +repository under the gra root, several at a time: ```sh -gra clean +gra fetch ``` -Example output: - ```text -Root: /home/me/gra -Repositories: 2 Worktrees: 2 - -REPOSITORY WORKTREE BRANCH STATUS VERDICT REASON -gra snowwolf feature ● dirty keep uncommitted changes -oans warmhare old-fix ✓ clean remove merged into origin/main - -Dry run. Re-run with --yes to remove 1 worktree(s). +Fetching 12 repositories +fetched 12 repositories ``` -Verdicts mean: +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 this is safe to run at any time, and what +makes the `SYNC` column of `gra ls` current. -* `keep` - the worktree has uncommitted changes or commits that are not merged - into origin's default branch. A clean worktree that would otherwise be - removable is also kept when it contains a `.grakeep` file. -* `remove` - the worktree is clean and its `HEAD` is already merged into - origin's default branch, or its commits are patch-equivalent to changes - already there after a squash or cherry-pick merge. -* `prune` - Git still knows about the worktree, but the directory no longer - exists on disk. - -By default, `gra clean` is a dry run. Use `--yes` to remove worktrees marked -`remove` and prune missing entries: - -```sh -gra clean --yes -``` - -Before classifying, `gra clean` runs `git fetch --prune origin` in each -repository. Use `--no-fetch` to skip that step. - -`gra clean --yes` deletes the local branch of each removed worktree - the -merged or patch-equivalent verification already happened as part of the -verdict. It runs each repository's `done.sh` before removing a worktree, like -`gra done` does. It never removes dirty worktrees. - -## migrate - rename worktrees to the current naming scheme - -Worktree names used to be a single four-letter word. `gra migrate` renames -every worktree still named that way, giving each the name the current `gra` -would have given it: - -```sh -gra migrate -``` +A repository that cannot be reached is named with the reason, and the others +are still fetched: ```text -Root: /home/me/gra -Repositories: 2 Worktrees to rename: 3 - -REPOSITORY WORKTREE NEW NAME -gra wolf snowwolf - rose goldfish -oans hare warmhare - -Dry run. Re-run with --yes to rename 3 worktree(s). +Fetching 12 repositories +oans: fatal: could not read from remote repository. +fetched 11 of 12 repositories ``` -Like `gra clean`, it is a dry run until you pass `--yes`: +## hooks - write missing hooks ```sh -gra migrate --yes +gra hooks ``` -Only the directory moves, with `git worktree move`, so Git follows it: -branches, commits, and uncommitted changes are all untouched. Names are handed -out exactly as `gra work` hands them out - the repository's own preference -order, skipping every name taken anywhere - so a migrated worktree gets the -same name it would have got had it been created today, and no two worktrees -can land on the same name. - -Worktrees already on the current scheme are left alone, so re-running it does -nothing. That also makes it safe on a machine where only some repositories -were touched. - -Two things it deliberately does not do. A tmux window opened for the old name -keeps that name; `gra work` inside the worktree re-runs `work.sh` and gets you -a window with the new one. And if your shell is standing in a worktree that -moved, `gra migrate` tells you where it went rather than moving you. +Writes `work.sh` and `done.sh` into every repository under the gra root that +is missing them, and leaves existing ones alone - so it cannot overwrite your +edits and is safe to re-run. ## cd - jump to a worktree @@ -567,7 +501,7 @@ eval "$(gra shell bash)" The same line installs Bash completion, so there is nothing else to set up: ```text -gra install clone ls work switch done cd shell hooks clean migrate +gra install clone fetch ls work switch done cd shell hooks gra cd warmhare goldfish snowwolf worktree names gra done warmhare goldfish snowwolf --force gra work branches, inside a repository diff --git a/gra b/gra index c6a1218..7bc46d0 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__ = "4.0.0" +__version__ = "5.0.0" import argparse from concurrent.futures import ThreadPoolExecutor @@ -50,7 +50,6 @@ VALID_PATH_NAME = re.compile(r"[A-Za-z0-9._-]+") # Claude may create its own temporary worktrees inside a checkout. Keep those # local-only directories out of `git status` without changing tracked files. CLAUDE_WORKTREES_EXCLUDE = ".claude/worktrees/" -GRAKEEP_FILE = ".grakeep" # Set to false in a repository's config by 'gra clone --no-submodules', or # globally to keep submodules out of every repository. @@ -62,7 +61,7 @@ SUBMODULES_CONFIG = "gra.submodules" GITHUB_HOST = "github.com" UPSTREAM_REMOTE = "upstream" UPSTREAM_CONFIG = "gra.upstream" -LOCAL_EXCLUDES = (CLAUDE_WORKTREES_EXCLUDE, GRAKEEP_FILE) +LOCAL_EXCLUDES = (CLAUDE_WORKTREES_EXCLUDE,) # Per-repository hooks, run when a worktree is created and before one is # removed. They live in the container (next to `.bare`), so they are personal @@ -123,8 +122,8 @@ window=$(tmux new-window -P -F '#{window_id}' \ DONE_HOOK = "done.sh" DONE_HOOK_DEFAULT = """\ #!/bin/sh -# Runs before 'gra done' or 'gra clean' removes a worktree, from the -# repository directory. The worktree still exists while this runs. +# Runs before 'gra done' removes a worktree, from the repository directory. +# The worktree still exists while this runs. # # gra runs this with these variables set: # @@ -150,10 +149,9 @@ done # Worktree names are a descriptor and a noun run together, e.g. warmhare: # memorable, always eight letters, and never colliding with a gra command # name. Both lists hold only common, concrete four-letter words that are easy -# to read aloud for non-native speakers - the fixed word length means every -# name splits unambiguously into its two words. A name is taken as long as -# any repository under the gra root uses it, so a name identifies one -# worktree across the whole machine. +# to read aloud for non-native speakers. A name is taken as long as any +# repository under the gra root uses it, so a name identifies one worktree +# across the whole machine. DESCRIPTORS = ( "aqua", "blue", "bold", "busy", "calm", "cold", "cool", "cozy", "cute", "dark", "dawn", "deep", "easy", "epic", "fair", "fast", "fine", "fire", @@ -177,11 +175,6 @@ NOUNS = ( "taxi", "tent", "tofu", "tree", "tuna", "vase", "wave", "wolf", "wood", ) -# Every word in both lists is this long, which is what lets a name be split -# back into the two words that built it - and lets 'gra migrate' recognize a -# name this version of gra could have produced. -WORD_LENGTH = 4 - _quiet = False _use_color = sys.stdout.isatty() and "NO_COLOR" not in os.environ if _use_color: @@ -256,11 +249,11 @@ T = TypeVar("T") U = TypeVar("U") -def parallel_map(fn: Callable[[T], U], items: list[T]) -> list[U]: +def parallel_map(fn: Callable[[T], U], items: list[T], workers: int = 8) -> list[U]: """Map fn over items with a bounded thread pool, preserving order.""" if len(items) <= 1: return [fn(item) for item in items] - with ThreadPoolExecutor(max_workers=min(8, len(items))) as executor: + with ThreadPoolExecutor(max_workers=min(workers, len(items))) as executor: return list(executor.map(fn, items)) @@ -365,7 +358,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}; " - f"run 'git fetch {UPSTREAM_REMOTE}' for its branches", + "run 'gra fetch' for its branches", GREEN, ) @@ -427,6 +420,19 @@ def containers(root: Path) -> list[Path]: ) +def repositories_or_report(root: Path) -> list[Path]: + """Every repository under the root, saying where it looked when there is none. + + How every command that walks the whole root starts, so they all answer an + empty root the same way instead of doing nothing visible. + """ + repositories = containers(root) + if not repositories: + print(f"Root: {root}") + print("No repositories found.") + return repositories + + def find_container(path: Path) -> Path | None: """Walk upward looking for a gra repo container.""" for candidate in [path, *path.parents]: @@ -602,12 +608,10 @@ def worktree_status_output(path: Path) -> str: return git_output(["status", "--porcelain"], cwd=path) or "" -def fmt_worktree_status(path: Path, status_output: str | None = None) -> str: +def fmt_worktree_status(path: Path) -> str: if not path.exists(): return "× missing" - if status_output is None: - status_output = worktree_status_output(path) - return "● dirty" if status_output else "✓ clean" + return "● dirty" if worktree_status_output(path) else "✓ clean" # --------------------------------------------------------------------------- @@ -671,21 +675,12 @@ def name_candidates(identity: str) -> list[str]: ] -def is_generated_name(name: str) -> bool: - """Whether this is a name the current word lists could have produced. - - Every word is WORD_LENGTH long, so a name splits at one known offset. - 'gra migrate' uses this to leave worktrees it has already renamed alone. - """ - return name[:WORD_LENGTH] in DESCRIPTORS and name[WORD_LENGTH:] in NOUNS - - def first_free_name(identity: str, taken: set[str]) -> str: """The repository's first name that nothing in `taken` is using.""" for name in name_candidates(identity): if name not in taken: return name - fail("all worktree names are taken; run 'gra clean' or 'gra done' first") + fail("all worktree names are taken; free one with 'gra done' first") def pick_worktree_name(root: Path, identity: str) -> str: @@ -735,13 +730,6 @@ def table_cell(header: str, value: str) -> str: return style(value, YELLOW) if value.startswith("×"): return style(value, RED) - if header == "VERDICT": - if value == "remove": - return style(value, GREEN) - if value == "prune": - return style(value, YELLOW) - if value == "keep": - return style(value, DIM) return value @@ -808,10 +796,8 @@ def write_missing_hooks(root: Path) -> None: Repositories cloned before hooks existed have none, and only 'gra clone' writes them, so without this they would stay silent forever. """ - repositories = containers(root) + repositories = repositories_or_report(root) if not repositories: - print(f"Root: {root}") - print("No repositories found.") return written = sum(write_default_hooks(container) for container in repositories) log( @@ -888,18 +874,75 @@ def clone( create_worktree(container, root, ref.removeprefix("origin/"), no_hook=no_hook) +# --------------------------------------------------------------------------- +# fetch command +# --------------------------------------------------------------------------- + + +def fetch_repository(bare: Path) -> str | None: + """Fetch every remote of one repository; the reason when it fails. + + Every remote, not just origin: a fork's 'upstream' is exactly what goes + stale, and gra adds no remotes beyond those two. + """ + result = subprocess.run( + ["git", "fetch", "--prune", "--all"], + cwd=bare, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if result.returncode == 0: + return None + # The last line is where git says what went wrong; the ones above it are + # progress, and one line per repository keeps the summary readable. + lines = result.stderr.strip().splitlines() + return lines[-1].strip() if lines else "git fetch failed" + + +def fetch_repositories(repositories: list[Path]) -> None: + """Fetch every repository in parallel, naming the ones that failed.""" + count = len(repositories) + word = "repository" if count == 1 else "repositories" + log(f"Fetching {count} {word}") + + # More threads than the default: these wait on the network rather than + # competing for a disk, so a machine with many repositories should not + # fetch them in waves of eight. + errors = parallel_map( + lambda container: fetch_repository(bare_dir(container)), repositories, workers=16 + ) + failed = [(container, error) for container, error in zip(repositories, errors) if error] + for container, error in failed: + log(f"{container.name}: {error}", YELLOW) + + if failed: + log(f"fetched {count - len(failed)} of {count} {word}", YELLOW) + else: + 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 # --------------------------------------------------------------------------- -def print_repositories(root: Path) -> None: +def print_repositories(root: Path, fetch: bool) -> None: """Show every gra repository and every worktree under the gra root.""" - repositories = containers(root) - print(f"Root: {root}") + repositories = repositories_or_report(root) if not repositories: - print("No repositories found.") return + if fetch: + fetch_repositories(repositories) + print(f"Root: {root}") active_worktree = git_toplevel(Path.cwd()) current = active_worktree.resolve() if active_worktree else None @@ -1290,7 +1333,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 switch done cd shell hooks clean migrate' \ + COMPREPLY=($(compgen -W 'install clone fetch ls work switch done cd shell hooks' \ -- "$cur")) return fi @@ -1301,8 +1344,7 @@ _gra() { case $cmd in clone) words='--name --no-work --no-hook --no-submodules --no-upstream' ;; done) words='--force' ;; - clean) words='--yes --no-fetch' ;; - migrate) words='--yes' ;; + ls) words='--fetch' ;; install) words='--no-check' ;; esac COMPREPLY=($(compgen -W "$words" -- "$cur")) @@ -1600,7 +1642,7 @@ def run_work_hook(container: Path, worktree: Path) -> None: # --------------------------------------------------------------------------- -# Merge state probes shared by done and clean +# Worktree removal # --------------------------------------------------------------------------- @@ -1690,252 +1732,6 @@ def done(name: str | None, root: Path, force: bool) -> None: remove_worktree(container, worktree, force=force, branch_merged=merged) -# --------------------------------------------------------------------------- -# clean command -# --------------------------------------------------------------------------- - - -def fetch_repository(bare: Path) -> None: - subprocess.run( - ["git", "fetch", "--prune", "origin"], - cwd=bare, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - - -def fetch_repositories(bares: list[Path]) -> None: - parallel_map(fetch_repository, bares) - - -def classify_worktree( - path: Path, - default_ref: str | None, - status_output: str | None = None, -) -> tuple[str, str]: - """Return (verdict, reason) for a worktree path. Verdict is keep/remove/prune.""" - if not path.exists(): - return ("prune", "missing path") - if (path / GRAKEEP_FILE).is_file(): - return ("keep", f"{GRAKEEP_FILE} marker") - if status_output is None: - status_output = worktree_status_output(path) - if status_output: - return ("keep", "uncommitted changes") - if not default_ref: - return ("keep", "origin default branch unknown") - if is_merged_into(path, default_ref): - return ("remove", f"merged into {default_ref}") - if has_patch_equivalent_changes_in(path, default_ref): - return ("remove", f"changes already in {default_ref}") - return ("keep", f"has commits not in {default_ref}") - - -class CleanEntry(NamedTuple): - path: Path - ref: str - status: str - verdict: str - reason: str - - -def clean_worktree_entry(path: Path, default_ref: str | None) -> CleanEntry: - status_output = worktree_status_output(path) if path.exists() else "" - verdict, reason = classify_worktree(path, default_ref, status_output) - return CleanEntry( - path, - worktree_ref(path), - fmt_worktree_status(path, status_output), - verdict, - reason, - ) - - -def clean_repository_entries(container: Path) -> list[CleanEntry]: - """Classify worktrees in one repository.""" - bare = bare_dir(container) - default_ref = remote_default_branch(bare) - return parallel_map( - lambda path: clean_worktree_entry(path, default_ref), worktree_paths(bare) - ) - - -def apply_clean(container: Path, entries: list[CleanEntry]) -> None: - """Remove removable worktrees and prune missing entries for one repository.""" - has_missing = False - for entry in entries: - if entry.verdict == "prune": - has_missing = True - elif entry.verdict == "remove": - remove_worktree(container, entry.path, force=False, branch_merged=True) - if has_missing: - log(f"Pruning missing worktree entries in '{container.name}'") - run_git(["worktree", "prune"], cwd=bare_dir(container)) - - -def clean(root: Path, yes: bool, fetch: bool) -> None: - """Report cleanable worktrees across every gra repository under root.""" - repositories = containers(root) - print(f"Root: {root}") - if not repositories: - print("No repositories found.") - return - - if fetch: - log(f"Fetching origin for {len(repositories)} repositories") - fetch_repositories([bare_dir(container) for container in repositories]) - - repo_entries = [ - (container, clean_repository_entries(container)) for container in repositories - ] - - rows = [] - for container, entries in repo_entries: - for index, entry in enumerate(entries): - rows.append( - [ - container.name if index == 0 else "", - entry.path.name, - entry.ref, - entry.status, - entry.verdict, - entry.reason, - ] - ) - - total_removable = sum( - 1 for _c, entries in repo_entries for e in entries if e.verdict == "remove" - ) - total_missing = sum( - 1 for _c, entries in repo_entries for e in entries if e.verdict == "prune" - ) - - print(f"Repositories: {len(repositories)} Worktrees: {len(rows)}") - print() - print_table(["REPOSITORY", "WORKTREE", "BRANCH", "STATUS", "VERDICT", "REASON"], rows) - - print() - if not total_removable and not total_missing: - log("Nothing to clean.", GREEN) - return - - if not yes: - actions = [] - if total_removable: - actions.append(f"remove {total_removable} worktree(s)") - if total_missing: - entries_word = "entry" if total_missing == 1 else "entries" - actions.append(f"prune {total_missing} missing {entries_word}") - log(f"Dry run. Re-run with --yes to {' and '.join(actions)}.", YELLOW) - return - - for container, entries in repo_entries: - if any(e.verdict in {"remove", "prune"} for e in entries): - apply_clean(container, entries) - - print() - log( - f"Removed {total_removable} worktree(s); pruned missing entries in {total_missing} place(s).", - GREEN, - ) - - -# --------------------------------------------------------------------------- -# migrate command -# --------------------------------------------------------------------------- - - -class Rename(NamedTuple): - container: Path - path: Path - new_name: str - - @property - def new_path(self) -> Path: - return self.path.parent / self.new_name - - -def planned_renames(root: Path) -> list[Rename]: - """Every worktree whose name predates the current scheme, and its new name. - - Names are handed out exactly as 'gra work' hands them out: the - repository's own preference order, skipping every name taken anywhere - - including the ones handed out earlier in this same plan, which is why the - taken set is carried across repositories rather than re-read per worktree. - """ - taken = taken_worktree_names(root) - renames: list[Rename] = [] - for container in containers(root): - identity = worktree_identity(container) - paths = sorted(worktree_paths(bare_dir(container)), key=lambda path: path.name) - for path in paths: - # A registered worktree whose directory is gone cannot be moved; - # 'gra clean' prunes those. - if is_generated_name(path.name) or not path.is_dir(): - continue - new_name = first_free_name(identity, taken) - taken.add(new_name) - renames.append(Rename(container, path, new_name)) - return renames - - -def apply_renames(renames: list[Rename]) -> None: - """Move each worktree to its new name, Git metadata and all.""" - for rename in renames: - log(f"Renaming '{rename.path.name}' to '{rename.new_name}' in '{rename.container.name}'") - run_git( - ["worktree", "move", rename.path, rename.new_path], - cwd=bare_dir(rename.container), - ) - - -def migrate(root: Path, yes: bool) -> None: - """Rename worktrees named by an older gra to the current scheme.""" - repositories = containers(root) - print(f"Root: {root}") - if not repositories: - print("No repositories found.") - return - - renames = planned_renames(root) - if not renames: - log("Every worktree already uses the current naming scheme.", GREEN) - return - - rows = [] - previous: Path | None = None - for rename in renames: - rows.append( - [ - rename.container.name if rename.container != previous else "", - rename.path.name, - rename.new_name, - ] - ) - previous = rename.container - - print(f"Repositories: {len(repositories)} Worktrees to rename: {len(renames)}") - print() - print_table(["REPOSITORY", "WORKTREE", "NEW NAME"], rows) - print() - - if not yes: - log(f"Dry run. Re-run with --yes to rename {len(renames)} worktree(s).", YELLOW) - return - - # Where the shell is standing has to be read before anything moves. - here = Path.cwd() - apply_renames(renames) - - print() - log(f"Renamed {len(renames)} worktree(s).", GREEN) - for rename in renames: - if here == rename.path or rename.path in here.parents: - log(f"the directory you are in moved; 'cd {rename.new_path}'", YELLOW) - break - - # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -2022,6 +1818,20 @@ 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, @@ -2029,10 +1839,14 @@ def parse_args() -> argparse.Namespace: description=( 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." + "it reads local refs only and never goes to the network.\n" + "Use --fetch to refresh every repository first, as 'gra fetch' does." ), ) - ls_parser.set_defaults(handler=lambda args: print_repositories(args.root)) + ls_parser.add_argument( + "--fetch", action="store_true", help="fetch every repository before listing" + ) + ls_parser.set_defaults(handler=lambda args: print_repositories(args.root, args.fetch)) work_parser = subparsers.add_parser( "work", @@ -2124,47 +1938,6 @@ def parse_args() -> argparse.Namespace: ) hooks_parser.set_defaults(handler=lambda args: write_missing_hooks(args.root)) - clean_parser = subparsers.add_parser( - "clean", - formatter_class=argparse.RawTextHelpFormatter, - help="report or remove clean merged worktrees", - description=( - f"Classify worktrees in every repository under '{root}':\n" - " keep - .grakeep marker, dirty, or has commits not in origin's default branch\n" - " remove - clean and merged or patch-equivalent in origin's default branch\n" - " prune - the worktree path no longer exists on disk\n" - "Without --yes, only prints the table.\n" - f"Each removal runs the repository's '{DONE_HOOK}' hook first." - ), - ) - clean_parser.add_argument( - "--yes", action="store_true", help="actually remove removable worktrees and prune missing ones" - ) - clean_parser.add_argument( - "--no-fetch", action="store_true", help="skip 'git fetch --prune origin' before classifying" - ) - clean_parser.set_defaults(handler=lambda args: clean(args.root, args.yes, not args.no_fetch)) - - migrate_parser = subparsers.add_parser( - "migrate", - formatter_class=argparse.RawTextHelpFormatter, - help="rename worktrees to the current naming scheme", - description=( - f"Rename every worktree under '{root}' that an older gra named,\n" - "using the name the current gra would have given it. Worktrees\n" - "already on the current scheme are left alone, so re-running\n" - "this does nothing. Without --yes, only prints the table.\n" - "Only the directory moves, with 'git worktree move'; branches,\n" - "commits and uncommitted changes are untouched.\n" - f"A tmux window opened for the old name keeps it until '{WORK_HOOK}'\n" - "runs again, which 'gra work' inside the worktree does." - ), - ) - migrate_parser.add_argument( - "--yes", action="store_true", help="actually rename the worktrees" - ) - migrate_parser.set_defaults(handler=lambda args: migrate(args.root, args.yes)) - args = parser.parse_args() args.root = root return args diff --git a/tests/test_gra_cli.py b/tests/test_gra_cli.py index 1ea9b27..64d80d7 100644 --- a/tests/test_gra_cli.py +++ b/tests/test_gra_cli.py @@ -186,7 +186,6 @@ def test_clone_with_no_work_creates_only_the_bare_repository(tmp_path: Path) -> assert git_output(["branch", "--list"], cwd=bare) == "" exclude = (bare / "info" / "exclude").read_text() assert ".claude/worktrees/" in exclude - assert ".grakeep" in exclude def test_clone_checks_out_the_default_branch(tmp_path: Path) -> None: @@ -995,24 +994,6 @@ def test_done_runs_the_done_hook_before_removing_the_worktree(tmp_path: Path) -> ] -def test_clean_runs_the_done_hook_for_each_removed_worktree(tmp_path: Path) -> None: - home = tmp_path / "home" - home.mkdir() - source = make_repo(tmp_path, "project") - container = clone_repo(home, source) - first = work_worktree(home, container, "main") - second = work_worktree(home, container) - hook_out = tmp_path / "hook-out" - write_hook(container, "done.sh", '#!/bin/sh\necho "$GRA_WORD" >> "$HOOK_OUT"\n') - - result = run_cli( - ["clean", "--yes", "--no-fetch"], home, env_extra={"HOOK_OUT": str(hook_out)} - ) - - assert result.returncode == 0, result.stderr - assert sorted(hook_out.read_text().split()) == sorted([first.name, second.name]) - - def test_work_finds_a_branch_by_ticket_key(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() @@ -1259,150 +1240,103 @@ def test_ls_lists_all_repositories_and_worktrees(tmp_path: Path) -> None: assert "● dirty" in result.stdout -def test_clean_treats_squash_merged_worktree_as_removable(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, "feature") - bare = container / BARE_DIR - - git(["merge", "--squash", "feature"], cwd=source) - git(["commit", "-m", "squash feature"], cwd=source) - - dry_run = run_cli(["clean"], home, cwd=tmp_path) - - assert dry_run.returncode == 0, dry_run.stderr - assert worktree.name in dry_run.stdout - assert "remove" in dry_run.stdout - assert "Dry run. Re-run with --yes to remove 1 worktree(s)." in dry_run.stdout - - apply_run = run_cli(["clean", "--yes"], home, cwd=tmp_path) - - assert apply_run.returncode == 0, apply_run.stderr - assert not worktree.exists() - assert git_fails(["show-ref", "--verify", "--quiet", "refs/heads/feature"], cwd=bare) - - -def test_clean_keeps_grakeep_worktree(tmp_path: Path) -> None: +def test_fetch_updates_and_prunes_every_repository(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, "feature") - (worktree / ".grakeep").write_text("") - - assert git_output(["status", "--porcelain", "--", ".grakeep"], cwd=worktree) == "" + first = make_repo(tmp_path, "one") + second = make_repo(tmp_path, "two") + add_feature_branch(first) + add_feature_branch(second) + first_bare = clone_repo(home, first) / BARE_DIR + second_bare = clone_repo(home, second) / BARE_DIR - git(["merge", "--squash", "feature"], cwd=source) - git(["commit", "-m", "squash feature"], cwd=source) + for source in (first, second): + add_feature_branch(source, "later") + git(["branch", "-D", "feature"], cwd=source) - result = run_cli(["clean"], home, cwd=tmp_path) + result = run_cli(["fetch"], home, cwd=tmp_path) assert result.returncode == 0, result.stderr - assert worktree.name in result.stdout - assert ".grakeep marker" in result.stdout - assert "Nothing to clean." in result.stdout - assert "Dry run. Re-run" not in result.stdout + assert "fetched 2 repositories" in result.stdout + for bare in (first_bare, second_bare): + assert not git_fails( + ["show-ref", "--verify", "--quiet", "refs/remotes/origin/later"], cwd=bare + ) + # --prune, so a branch gone from the remote is gone here too. + assert git_fails( + ["show-ref", "--verify", "--quiet", "refs/remotes/origin/feature"], cwd=bare + ) -def add_old_style_worktree(container: Path, name: str, branch: str | None = "main") -> Path: - """A worktree named the way gra named them before two-word names. - - Without a branch the worktree is detached at origin's default branch, - which is how a repository gets a second one while the first holds `main`. - """ - worktree = container / name - args = ["worktree", "add"] - args += [str(worktree), branch] if branch else ["--detach", str(worktree), "origin/main"] - git(args, cwd=container / BARE_DIR) - return worktree - - -def test_migrate_renames_worktrees_named_by_an_older_gra(tmp_path: Path) -> None: +def test_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() source = make_repo(tmp_path, "project") - container = clone_repo(home, source) - old = add_old_style_worktree(container, "hare") + parent = make_repo(tmp_path, "parent") + bare = clone_repo(home, source) / BARE_DIR + git(["remote", "add", "upstream", str(parent)], cwd=bare) + add_feature_branch(parent) - result = run_cli(["migrate", "--yes"], home, cwd=tmp_path) + result = run_cli(["fetch"], home, cwd=tmp_path) assert result.returncode == 0, result.stderr - assert not old.exists() - # The name it gets is the one 'gra work' would have given it. - expected = load_gra().name_candidates("project")[0] - assert (container / expected).is_dir() - # Git followed the directory, so the worktree is still usable. - assert git_output(["branch", "--show-current"], cwd=container / expected) == "main" - assert str(container / expected) in git_output( - ["worktree", "list"], cwd=container / BARE_DIR + assert not git_fails( + ["show-ref", "--verify", "--quiet", "refs/remotes/upstream/feature"], cwd=bare ) -def test_migrate_is_a_dry_run_without_yes(tmp_path: Path) -> None: +def test_fetch_reports_an_unreachable_repository_and_fetches_the_rest( + tmp_path: Path, +) -> None: home = tmp_path / "home" home.mkdir() - source = make_repo(tmp_path, "project") - container = clone_repo(home, source) - old = add_old_style_worktree(container, "hare") + broken_bare = clone_repo(home, make_repo(tmp_path, "broken")) / BARE_DIR + working = make_repo(tmp_path, "working") + working_bare = clone_repo(home, working) / BARE_DIR + git(["remote", "set-url", "origin", str(tmp_path / "gone")], cwd=broken_bare) + add_feature_branch(working) - result = run_cli(["migrate"], home, cwd=tmp_path) + result = run_cli(["fetch"], home, cwd=tmp_path) + # One repository nobody can reach must not cost the others their fetch. assert result.returncode == 0, result.stderr - assert "Dry run. Re-run with --yes" in result.stdout - assert "hare" in result.stdout - assert old.is_dir() + assert "broken" in result.stdout + assert "fetched 1 of 2 repositories" in result.stdout + assert not git_fails( + ["show-ref", "--verify", "--quiet", "refs/remotes/origin/feature"], cwd=working_bare + ) -def test_migrate_leaves_current_names_alone(tmp_path: Path) -> None: +def test_fetch_without_repositories_says_so(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") - result = run_cli(["migrate", "--yes"], home, cwd=tmp_path) + result = run_cli(["fetch"], home, cwd=tmp_path) assert result.returncode == 0, result.stderr - assert "already uses the current naming scheme" in result.stdout - assert worktree.is_dir() + assert "No repositories found." in result.stdout -def test_migrate_keeps_uncommitted_changes(tmp_path: Path) -> None: +def test_ls_fetch_refreshes_the_sync_column(tmp_path: Path) -> None: home = tmp_path / "home" home.mkdir() source = make_repo(tmp_path, "project") container = clone_repo(home, source) - old = add_old_style_worktree(container, "hare") - (old / "README.md").write_text("# edited\n") + work_worktree(home, container, "main") + (source / "README.md").write_text("# moved on\n") + git(["commit", "-am", "second"], cwd=source) - result = run_cli(["migrate", "--yes"], home, cwd=tmp_path) + offline = run_cli(["ls"], home, cwd=tmp_path) - assert result.returncode == 0, result.stderr - moved = container / load_gra().name_candidates("project")[0] - assert (moved / "README.md").read_text() == "# edited\n" - assert git_output(["status", "--porcelain"], cwd=moved) == "M README.md" - - -def test_migrate_gives_every_worktree_a_distinct_name(tmp_path: Path) -> None: - """Two repositories migrating at once must not land on the same name.""" - home = tmp_path / "home" - home.mkdir() - first = clone_repo(home, make_repo(tmp_path, "one")) - second = clone_repo(home, make_repo(tmp_path, "two")) - for container in (first, second): - add_old_style_worktree(container, "hare" if container is first else "wolf") - add_old_style_worktree(container, "rose" if container is first else "puma", None) + assert offline.returncode == 0, offline.stderr + assert "↓1" not in offline.stdout - result = run_cli(["migrate", "--yes"], home, cwd=tmp_path) + fetched = run_cli(["ls", "--fetch"], home, cwd=tmp_path) - assert result.returncode == 0, result.stderr - names = [path.name for container in (first, second) for path in worktree_dirs(container)] - assert len(names) == 4 - assert len(set(names)) == 4 + assert fetched.returncode == 0, fetched.stderr + assert "Fetching 1 repository" in fetched.stdout + assert "↓1" in fetched.stdout def test_cd_with_name_prints_worktree_path(tmp_path: Path) -> None: @@ -1495,7 +1429,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", "cl"], tmp_path) == ["clone", "clean"] + assert complete(home, ["gra", "f"], tmp_path) == ["fetch"] def test_completion_offers_every_subcommand(tmp_path: Path) -> None: diff --git a/tests/test_gra_helpers.py b/tests/test_gra_helpers.py index f0f9ef0..b3f7811 100644 --- a/tests/test_gra_helpers.py +++ b/tests/test_gra_helpers.py @@ -1,5 +1,6 @@ """Focused tests for gra helper functions.""" +import subprocess import sys import threading from pathlib import Path @@ -12,6 +13,11 @@ gra = load_gra() +def completed(returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess[str]: + """What a patched subprocess.run hands back, of the type the real one does.""" + return subprocess.CompletedProcess(args=["git"], returncode=returncode, stderr=stderr) + + def test_older_python_is_rejected_with_a_message( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -60,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", "switch", "done", "cd", "shell", "hooks", "clean"} + reserved = {"install", "clone", "fetch", "ls", "work", "switch", "done", "cd", "shell", "hooks"} 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. @@ -223,59 +229,39 @@ def test_branch_needs_tracking_creates_missing_branch( def test_fetch_repositories_runs_fetches_in_parallel(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[tuple[list[str], Path, int, int, bool]] = [] + calls: list[tuple[list[str], Path, bool]] = [] lock = threading.Lock() both_started = threading.Event() - def fake_run( - args: list[str], *, cwd: Path, stdout: int, stderr: int, check: bool - ) -> object: + def fake_run(args: list[str], *, cwd: Path, check: bool, **kwargs: object) -> object: with lock: - calls.append((args, cwd, stdout, stderr, check)) + calls.append((args, cwd, check)) if len(calls) == 2: both_started.set() + # A serial implementation never gets both started, so it waits out the + # second here and the assert turns that into a failure. assert both_started.wait(1) - return object() + return completed() monkeypatch.setattr(gra.subprocess, "run", fake_run) - gra.fetch_repositories([Path("repo-a/.bare"), Path("repo-b/.bare")]) + gra.fetch_repositories([Path("root/repo-a"), Path("root/repo-b")]) - assert {call[1] for call in calls} == {Path("repo-a/.bare"), Path("repo-b/.bare")} - assert all(call[0] == ["git", "fetch", "--prune", "origin"] for call in calls) - assert all(call[4] is False for call in calls) + assert {call[1] for call in calls} == {Path("root/repo-a/.bare"), Path("root/repo-b/.bare")} + assert all(call[0] == ["git", "fetch", "--prune", "--all"] for call in calls) + assert all(call[2] is False for call in calls) -def test_clean_repository_entries_builds_worktree_entries_in_parallel( +def test_fetch_repository_reports_the_last_line_of_the_error( monkeypatch: pytest.MonkeyPatch, ) -> None: - calls: list[Path] = [] - lock = threading.Lock() - both_started = threading.Event() + error = "Fetching origin\nfatal: could not read from remote repository\n" + monkeypatch.setattr(gra.subprocess, "run", lambda *a, **k: completed(128, error)) - monkeypatch.setattr(gra, "remote_default_branch", lambda bare: "origin/main") - monkeypatch.setattr( - gra, - "worktree_paths", - lambda bare: [Path("repo/wolf"), Path("repo/lynx")], + assert gra.fetch_repository(Path("repo/.bare")) == ( + "fatal: could not read from remote repository" ) - def fake_clean_worktree_entry( - path: Path, default_ref: str - ) -> tuple[Path, str, str, str, str]: - with lock: - calls.append(path) - if len(calls) == 2: - both_started.set() - assert both_started.wait(1) - return (path, path.name, "✓ clean", "keep", default_ref) - - monkeypatch.setattr(gra, "clean_worktree_entry", fake_clean_worktree_entry) - - entries = gra.clean_repository_entries(Path("repo")) - - assert [entry[0] for entry in entries] == [Path("repo/wolf"), Path("repo/lynx")] - def script(version: str) -> bytes: return f'#!/usr/bin/env python3\n__version__ = "{version}"\n'.encode()