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()