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 }} diff --git a/README.md b/README.md index 7f4aedd..abea3a4 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? ``` @@ -64,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. @@ -88,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,15 +110,14 @@ 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 cd `](#gra-cd) | Jump to a worktree by name | | [`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 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 -*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 @@ -436,12 +405,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)" @@ -474,34 +445,59 @@ 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`. +fetch. + +`--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: + +```text +Fetching 12 repositories +oans: fatal: could not read from remote repository. +fetched 11 of 12 repositories +``` -## `gra fetch` +## `gra each` -Runs `git fetch --prune --all` in every repository under the gra root, several -at a time: +Runs a command of your own once in every repository under the gra root: ```sh -gra fetch +gra each git fetch --all --tags +gra each git gc +gra each du -sh . ``` +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 -Fetching 12 repositories -fetched 12 repositories +gra: git fetch --all --tags +Fetching origin +oans: git fetch --all --tags +Fetching origin +Fetching upstream ``` -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. +With `--wt` the command runs in every worktree instead, one run per +worktree, labelled `/`: -A repository that cannot be reached is named with the reason, and the others -are still fetched: +```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: ```text -Fetching 12 repositories -oans: fatal: could not read from remote repository. -fetched 11 of 12 repositories +ERROR: failed in: oans ``` ## `gra install` @@ -552,7 +548,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 +556,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,12 +631,13 @@ 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 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 switch 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 @@ -659,13 +655,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..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__ = "6.0.0" +__version__ = "8.2.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,11 +805,55 @@ 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.""" +# --------------------------------------------------------------------------- +# each command +# --------------------------------------------------------------------------- + + +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 repositories: - fetch_repositories(repositories) + 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 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( + "gra each needs a command to run, for example:\n" + " gra each git fetch --all --tags" + ) + failed: list[str] = [] + for label, directory in each_targets(root, worktrees): + log(f"{label}: {shlex.join(command)}") + try: + 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(label) + if failed: + fail(f"failed in: {', '.join(failed)}") # --------------------------------------------------------------------------- @@ -874,7 +918,7 @@ def print_repositories(root: Path, fetch: bool) -> None: # --------------------------------------------------------------------------- -# Picker helpers (cd, work, switch) +# Worktree name lookup, and the fzf table behind the branch picker # --------------------------------------------------------------------------- @@ -883,10 +927,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}'") @@ -923,47 +966,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. @@ -980,26 +982,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 # --------------------------------------------------------------------------- @@ -1302,9 +1290,10 @@ _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 fetch ls work switch done cd shell' \ + COMPREPLY=($(compgen -W 'install clone ls each work done cd shell' \ -- "$cur")) return fi @@ -1316,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")) @@ -1332,10 +1322,10 @@ _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") + each) + # 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' @@ -1413,7 +1403,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 +1413,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 +1518,7 @@ def create_worktree( # --------------------------------------------------------------------------- -# work and switch commands +# work command # --------------------------------------------------------------------------- @@ -1566,43 +1555,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. @@ -1854,7 +1810,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") @@ -1982,20 +1938,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 +1946,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( @@ -2012,6 +1957,38 @@ 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 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" + "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, + metavar="command", + help="the command and its arguments", + ) + each_parser.set_defaults( + handler=lambda args: run_in_each(args.root, args.command, worktrees=args.wt) + ) + work_parser = subparsers.add_parser( "work", formatter_class=argparse.RawTextHelpFormatter, @@ -2036,23 +2013,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, @@ -2080,14 +2040,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 75bdae8..c2303d0 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 @@ -1472,6 +1360,117 @@ 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_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() + 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() @@ -1497,25 +1496,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]: @@ -1562,7 +1554,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 +1600,20 @@ 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_command_names_for_each(tmp_path: Path) -> None: + home = tmp_path / "home" + 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) == [] 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..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", "fetch", "ls", "work", "switch", "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.