git-worktrees 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. git_worktrees-0.1.0/.agents/skills/release/SKILL.md +128 -0
  2. git_worktrees-0.1.0/.claude/settings.json +5 -0
  3. git_worktrees-0.1.0/.github/workflows/publish.yml +202 -0
  4. git_worktrees-0.1.0/.github/workflows/tests.yml +77 -0
  5. git_worktrees-0.1.0/.gitignore +7 -0
  6. git_worktrees-0.1.0/.pre-commit-config.yaml +35 -0
  7. git_worktrees-0.1.0/.python-version +1 -0
  8. git_worktrees-0.1.0/AGENTS.md +230 -0
  9. git_worktrees-0.1.0/CHANGELOG.md +70 -0
  10. git_worktrees-0.1.0/LICENSE +201 -0
  11. git_worktrees-0.1.0/PKG-INFO +335 -0
  12. git_worktrees-0.1.0/README.md +310 -0
  13. git_worktrees-0.1.0/conf.d/worktrees.fish +2 -0
  14. git_worktrees-0.1.0/docs/DEVELOPMENT.md +214 -0
  15. git_worktrees-0.1.0/functions/gwa.fish +12 -0
  16. git_worktrees-0.1.0/functions/gwl.fish +12 -0
  17. git_worktrees-0.1.0/functions/gwm.fish +12 -0
  18. git_worktrees-0.1.0/functions/gwr.fish +12 -0
  19. git_worktrees-0.1.0/pyproject.toml +105 -0
  20. git_worktrees-0.1.0/renovate.json +11 -0
  21. git_worktrees-0.1.0/scripts/verify-publish.bash +55 -0
  22. git_worktrees-0.1.0/src/worktrees/__init__.py +8 -0
  23. git_worktrees-0.1.0/src/worktrees/cli.py +752 -0
  24. git_worktrees-0.1.0/src/worktrees/forge.py +115 -0
  25. git_worktrees-0.1.0/src/worktrees/git.py +302 -0
  26. git_worktrees-0.1.0/src/worktrees/layout.py +52 -0
  27. git_worktrees-0.1.0/src/worktrees/merged.py +103 -0
  28. git_worktrees-0.1.0/src/worktrees/new_branch.py +79 -0
  29. git_worktrees-0.1.0/src/worktrees/pick.py +105 -0
  30. git_worktrees-0.1.0/src/worktrees/prune.py +170 -0
  31. git_worktrees-0.1.0/src/worktrees/repo.py +323 -0
  32. git_worktrees-0.1.0/src/worktrees/rotate.py +142 -0
  33. git_worktrees-0.1.0/src/worktrees/verdicts.py +161 -0
  34. git_worktrees-0.1.0/src/worktrees/worktree.py +221 -0
  35. git_worktrees-0.1.0/tests/conftest.py +90 -0
  36. git_worktrees-0.1.0/tests/test_cli.py +319 -0
  37. git_worktrees-0.1.0/tests/test_forge.py +195 -0
  38. git_worktrees-0.1.0/tests/test_gates.py +369 -0
  39. git_worktrees-0.1.0/tests/test_new_branch.py +172 -0
  40. git_worktrees-0.1.0/tests/test_rotate.py +203 -0
  41. git_worktrees-0.1.0/tests/test_shims.py +224 -0
  42. git_worktrees-0.1.0/tests/test_spec.py +145 -0
  43. git_worktrees-0.1.0/tests/test_version.py +57 -0
  44. git_worktrees-0.1.0/tests/test_worktree.py +282 -0
  45. git_worktrees-0.1.0/uv.lock +234 -0
  46. git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwa +9 -0
  47. git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwl +9 -0
  48. git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwm +9 -0
  49. git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwr +9 -0
  50. git_worktrees-0.1.0/zsh/plugins/worktrees/worktrees.plugin.zsh +14 -0
@@ -0,0 +1,128 @@
1
+ ---
2
+ name: release
3
+ description: Release a version of this package to PyPI. Bumps the version on a release branch, opens and merges the pull request once its checks pass, then tags the merged commit so publish.yml uploads it. Use when the user says "release", "cut a release", "ship x.y.z", or "/release x.y.z".
4
+ user-invocable: true
5
+ allowed-tools: Bash(git:*), Bash(gh:*), Bash(uv:*), Bash(rt:*), Bash(scripts/*.bash:*)
6
+ ---
7
+
8
+ # release
9
+
10
+ Release `$ARGUMENTS` of this package. The argument is the version, with or
11
+ without a leading `v`; strip it and use the bare `x.y.z` everywhere below.
12
+
13
+ With no argument, ask which version. Do not guess one from the commits.
14
+
15
+ Every step is one command. Run them in order, read what each says before
16
+ running the next, and stop at the first failure rather than working around
17
+ it. Nothing here is worth improvising: a release that goes wrong is on PyPI
18
+ forever.
19
+
20
+ ## 1. Everything that has to be true first
21
+
22
+ ```bash
23
+ git fetch --all --prune
24
+ rt release::prechecks <version> \
25
+ --branch main \
26
+ --check-registry-url "https://pypi.org/pypi/$(uv version | cut -d' ' -f1)/<version>/json"
27
+ ```
28
+
29
+ That checks the shape of the version, a clean working tree, that the tag is
30
+ free on the remote, that the version is after the newest release tag, that
31
+ HEAD is on main, and that PyPI does not already carry it. If it refuses, say
32
+ what it said and stop. Do not fix a dirty tree by stashing on the user's
33
+ behalf.
34
+
35
+ `rt` is `releasetools/cli`, installed with `brew install releasetools/tap/releasetools-cli`
36
+ or the installer in its README. Every check it runs refuses when it cannot
37
+ prove what it was asked to prove, so a doubtful answer stops the release
38
+ rather than passing it.
39
+
40
+ ## 2. The branch, the notes, and the bump
41
+
42
+ ```bash
43
+ git switch --create release/v<version> --no-track origin/main
44
+ ```
45
+
46
+ Then run `/release-notes:draft <version>`, from the ReleaseTools plugin. It
47
+ rules on every commit since the previous tag, writes the body to
48
+ `.git/RELEASE_EDITMSG`, and puts the entry in `CHANGELOG.md` under
49
+ `## <version> - <date>`. It shows the draft and waits before its last step,
50
+ so the user changes it there.
51
+
52
+ Do not write that entry by hand and do not skip it: `publish.yml` reads the
53
+ section back out for the GitHub release, and `build` refuses a tag whose
54
+ version has no section.
55
+
56
+ ```bash
57
+ uv version <version>
58
+ git commit --all --message "Release <version>"
59
+ git push --set-upstream origin refs/heads/release/v<version>
60
+ ```
61
+
62
+ `uv version` rewrites `pyproject.toml` and re-locks, so the commit carries
63
+ those two files and `CHANGELOG.md`. Check that with `git show --stat`
64
+ before pushing: a commit
65
+ missing `uv.lock` fails `uv sync --locked` in CI, and it fails after the
66
+ merge rather than before it.
67
+
68
+ Branch from `origin/main` rather than from `main`, so a stale local copy
69
+ cannot become the release.
70
+
71
+ ## 3. The pull request, and its checks
72
+
73
+ ```bash
74
+ gh pr create --base main --title "Release <version>" --body "..."
75
+ gh pr checks --watch --fail-fast
76
+ ```
77
+
78
+ The body is the changelog entry just written. It is already the summary,
79
+ already reviewed by the reader, and writing a second one invites the two to
80
+ disagree.
81
+
82
+ `--watch` blocks. When it reports a failure, report which check failed and
83
+ its URL, and stop. The branch and the pull request stay; nothing has been
84
+ tagged and nothing published.
85
+
86
+ ## 4. Merge, then wait for main
87
+
88
+ ```bash
89
+ gh pr merge --rebase --delete-branch
90
+ git switch main && git pull --ff-only
91
+ rt github::await_workflow "$(git rev-parse HEAD)" tests.yml
92
+ ```
93
+
94
+ `--rebase` keeps the commit message rather than replacing it with the pull
95
+ request title.
96
+
97
+ The wait matters. A rebase onto a main that moved is a tree neither the
98
+ branch nor main has tested, and the tag must only ever land on a commit
99
+ already proved green. `github::await_workflow` blocks until `tests.yml` finishes
100
+ on that commit and exits non-zero if it failed.
101
+
102
+ If it failed: `main` now carries the version bump and no release exists. Say
103
+ so plainly. The fix is another pull request, and then this skill again at the
104
+ same version, since no tag was created.
105
+
106
+ ## 5. Tag, which is what publishes
107
+
108
+ ```bash
109
+ git tag --annotate v<version> --message "v<version>"
110
+ git push origin refs/tags/v<version>
111
+ gh run watch "$(gh run list --workflow publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
112
+ ```
113
+
114
+ Pushing the tag is the release. `publish.yml` re-checks the tag against
115
+ `pyproject.toml`, that the commit is on `main`, and the tests, then builds
116
+ once and uploads to TestPyPI before PyPI.
117
+
118
+ Report the PyPI URL and the GitHub release URL when it finishes.
119
+
120
+ ## What this never does
121
+
122
+ Move a tag. A tag names one commit forever; a version that needs a second
123
+ attempt gets the next number. If `publish.yml` fails *after* the PyPI upload,
124
+ in `verify` or `release`, re-run it with
125
+ `gh workflow run publish.yml --ref v<version>` rather than re-tagging.
126
+
127
+ Delete a release branch that has not merged, stash the user's work, force
128
+ anything, or retry a failed check by re-running it in the hope it passes.
@@ -0,0 +1,5 @@
1
+ {
2
+ "enabledPlugins": {
3
+ "release-notes@release-tools": true
4
+ }
5
+ }
@@ -0,0 +1,202 @@
1
+ name: Publish
2
+
3
+ # Renaming this file breaks publishing. PyPI's Trusted Publishing matches a
4
+ # request against the repository, this workflow's FILENAME and the environment,
5
+ # so a rename means re-registering the publisher on PyPI first.
6
+ #
7
+ # build -> publish-test -> publish -> release
8
+ #
9
+ # Nothing here creates a tag. A release is a version bump merged into main and
10
+ # then tagged by hand, so the irreversible step is the last one and everything
11
+ # before it is a normal reviewed change.
12
+ #
13
+ # TestPyPI gates the real upload deliberately: a PyPI upload cannot be undone
14
+ # or replaced, so if the rehearsal fails there is nothing to pin against and
15
+ # the run stops there.
16
+ #
17
+ # No API token is stored anywhere. See README, "Publishing a version".
18
+
19
+ on:
20
+ push:
21
+ tags:
22
+ - "v*.*.*"
23
+ # To re-run a release whose publish failed. Run it against a tag ref, not a
24
+ # branch: every guard below reads the tag at HEAD.
25
+ workflow_dispatch:
26
+
27
+ permissions:
28
+ contents: read
29
+
30
+ jobs:
31
+ # Built once and handed to every job below, so what reaches PyPI is
32
+ # byte-for-byte what TestPyPI accepted.
33
+ build:
34
+ name: Build
35
+ runs-on: ubuntu-latest
36
+ permissions:
37
+ contents: read
38
+ # Reading another workflow's runs. Without it the tests guard below
39
+ # gets 403 from /actions/workflows/tests.yml and refuses every release,
40
+ # which is the safe direction and still a release that cannot happen.
41
+ actions: read
42
+ steps:
43
+ - name: Checkout code
44
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
45
+ with:
46
+ # A tag-ref checkout fetches only the tag it is checking out, and
47
+ # check-tag-version.bash asks for every tag at HEAD.
48
+ fetch-tags: true
49
+
50
+ - name: Set up uv
51
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
52
+ with:
53
+ python-version: "3.14"
54
+ enable-cache: true
55
+
56
+ - name: Install the release tools
57
+ uses: releasetools/cli@v0
58
+
59
+ # Four guards, cheapest first. The tag has to name a version the
60
+ # repository actually carries, that version has to have a changelog
61
+ # entry, the commit has to be one main took, and its tests have to have
62
+ # passed already. All of them here in build, because the release job
63
+ # runs after the PyPI upload and a refusal there would be too late.
64
+ - name: Check the tag matches the project version
65
+ run: rt git::assert_tag_version "$(uv version --short)"
66
+
67
+ - name: Check the changelog has an entry for it
68
+ run: rt changelog::section "${GITHUB_REF_NAME#v}" >/dev/null
69
+
70
+ # github:: rather than git::, because this checkout is shallow and
71
+ # merge-base answers from the history it has: on a truncated one it
72
+ # reports a commit main genuinely took as not on main, and refuses a
73
+ # release that should go ahead. The API needs no local history.
74
+ - name: Check the commit is on main
75
+ env:
76
+ GH_TOKEN: ${{ github.token }}
77
+ GH_REPO: ${{ github.repository }}
78
+ run: rt github::assert_on_branch main
79
+
80
+ - name: Wait for the tests on this commit
81
+ env:
82
+ GH_TOKEN: ${{ github.token }}
83
+ GH_REPO: ${{ github.repository }}
84
+ run: rt github::await_workflow "$GITHUB_SHA" tests.yml
85
+
86
+ - name: Install dependencies
87
+ run: uv sync --all-extras --locked
88
+
89
+ # The tag is the last chance to catch a regression, and it costs 20s.
90
+ - name: Run tests
91
+ run: uv run --no-sync pytest tests -q
92
+
93
+ - name: Build
94
+ run: uv build
95
+
96
+ - name: Upload distributions
97
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
98
+ with:
99
+ name: dist
100
+ path: dist/
101
+ if-no-files-found: error
102
+
103
+ publish-test:
104
+ name: Publish to TestPyPI
105
+ runs-on: ubuntu-latest
106
+ needs: build
107
+ # Binds this job to the trusted publisher registered for `testpypi`.
108
+ environment: testpypi
109
+ permissions:
110
+ # Lets the job mint the OIDC token PyPI exchanges for a short-lived,
111
+ # scoped credential. Without it uv has nothing to present.
112
+ id-token: write
113
+ contents: read
114
+ steps:
115
+ # `--index testpypi` resolves that name from [[tool.uv.index]] in
116
+ # pyproject.toml, which is not part of the built artifact.
117
+ - name: Checkout code
118
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
119
+
120
+ - name: Set up uv
121
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
122
+ with:
123
+ python-version: "3.14"
124
+ enable-cache: true
125
+
126
+ - name: Download distributions
127
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
128
+ with:
129
+ name: dist
130
+ path: dist/
131
+
132
+ - name: Publish
133
+ # `always` rather than the default `automatic`: fail loudly if OIDC is
134
+ # unavailable instead of quietly looking for a token.
135
+ run: uv publish --index testpypi --trusted-publishing always
136
+
137
+ publish:
138
+ name: Publish to PyPI
139
+ runs-on: ubuntu-latest
140
+ needs: publish-test
141
+ # Binds this job to the trusted publisher registered for `pypi`. Add a
142
+ # required reviewer on this environment for a manual gate before anything
143
+ # reaches the real index.
144
+ environment: pypi
145
+ permissions:
146
+ id-token: write
147
+ contents: read
148
+ steps:
149
+ - name: Checkout code
150
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
151
+
152
+ - name: Set up uv
153
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
154
+ with:
155
+ python-version: "3.14"
156
+ enable-cache: true
157
+
158
+ - name: Download distributions
159
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
160
+ with:
161
+ name: dist
162
+ path: dist/
163
+
164
+ - name: Install the release tools
165
+ uses: releasetools/cli@v0
166
+
167
+ - name: Publish
168
+ run: uv publish --trusted-publishing always
169
+
170
+ # An index serves what it has just accepted only after a delay, and a fixed
171
+ # sleep either wastes time or fails a publish that worked. This backs off
172
+ # across about 7.75 minutes and stops as soon as the version is there.
173
+ - name: Wait for the PyPI index to catch up
174
+ run: |
175
+ rt net::await_url "https://pypi.org/pypi/$(uv version | tr ' ' '/')/json"
176
+
177
+ - name: Verify it installs from PyPI
178
+ run: scripts/verify-publish.bash --prod
179
+
180
+ release:
181
+ name: Release
182
+ runs-on: ubuntu-latest
183
+ needs: publish
184
+ permissions:
185
+ contents: write
186
+ steps:
187
+ - name: Checkout code
188
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
189
+
190
+ - name: Install the release tools
191
+ uses: releasetools/cli@v0
192
+
193
+ # What was written for this version, not a list of pull request titles.
194
+ # build already proved the section exists, before anything was published.
195
+ - name: Read the changelog entry
196
+ run: rt changelog::section "${GITHUB_REF_NAME#v}" >RELEASE_NOTES.md
197
+
198
+ - name: Release
199
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
200
+ with:
201
+ body_path: RELEASE_NOTES.md
202
+ make_latest: true
@@ -0,0 +1,77 @@
1
+ name: Tests
2
+
3
+ on:
4
+ # Every pull request, not only those into main: a stacked branch targets the
5
+ # one below it, and a filter on `main` leaves that pull request with no
6
+ # checks at all.
7
+ pull_request:
8
+ push:
9
+ branches:
10
+ - main
11
+
12
+ # Least privilege: this reads the repository and nothing else. Without a
13
+ # default, GITHUB_TOKEN's scopes come from the repository setting, which is
14
+ # invisible from here.
15
+ permissions:
16
+ contents: read
17
+
18
+ # A second push to a pull request cancels the run still going for the first.
19
+ concurrency:
20
+ group: ${{ github.workflow }}-${{ github.ref }}
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ lint:
25
+ name: Lint
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - name: Checkout code
29
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
30
+
31
+ - name: Set up uv and Python
32
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
33
+ with:
34
+ python-version: "3.14"
35
+ enable-cache: true
36
+
37
+ - name: Install dependencies
38
+ run: uv sync --all-extras --locked
39
+
40
+ # ruff and mypy both run from here, so what CI checks and what a commit
41
+ # checks are the same versions pinned in the same file.
42
+ - name: Run pre-commit
43
+ run: uv run --no-sync pre-commit run --all-files --show-diff-on-failure
44
+
45
+ test:
46
+ name: Python ${{ matrix.python-version }}
47
+ runs-on: ubuntu-latest
48
+ strategy:
49
+ # One version failing is a fact about that version, so let the others
50
+ # finish and say which.
51
+ fail-fast: false
52
+ matrix:
53
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
54
+
55
+ steps:
56
+ - name: Checkout code
57
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
58
+
59
+ - name: Set up uv and Python ${{ matrix.python-version }}
60
+ uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
61
+ with:
62
+ python-version: ${{ matrix.python-version }}
63
+ enable-cache: true
64
+
65
+ - name: Install dependencies
66
+ # --locked fails the run on a stale uv.lock, rather than quietly
67
+ # resolving something the lock file never described.
68
+ run: uv sync --all-extras --locked
69
+
70
+ # The suite drives real git: it builds repositories, adds worktrees and
71
+ # removes them. Recording the version makes a failure that is git's
72
+ # rather than ours readable from the log alone.
73
+ - name: Record the git version
74
+ run: git --version
75
+
76
+ - name: Run tests
77
+ run: uv run --no-sync pytest tests -q
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .venv/
7
+ dist/
@@ -0,0 +1,35 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ # keep-sorted start
6
+ - id: check-added-large-files
7
+ - id: check-ast
8
+ - id: check-docstring-first
9
+ - id: check-json
10
+ - id: check-toml
11
+ - id: check-yaml
12
+ - id: debug-statements
13
+ - id: end-of-file-fixer
14
+ - id: trailing-whitespace
15
+ # keep-sorted end
16
+
17
+ - repo: https://github.com/astral-sh/ruff-pre-commit
18
+ rev: v0.16.6
19
+ hooks:
20
+ - id: ruff-check
21
+ args: ["--fix"]
22
+ - id: ruff-format
23
+
24
+ - repo: https://github.com/pre-commit/mirrors-mypy
25
+ rev: v2.3.1
26
+ hooks:
27
+ - id: mypy
28
+ # The suite imports the package and pytest; without these mypy reads
29
+ # every one of them as Any and reports nothing about how they are used.
30
+ additional_dependencies: ["pytest==9.1.1"]
31
+
32
+ - repo: https://github.com/google/keep-sorted
33
+ rev: v0.10.0
34
+ hooks:
35
+ - id: keep-sorted
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,230 @@
1
+ # Working in this repository
2
+
3
+ A Python CLI for git worktrees. `pyproject.toml` declares one console script
4
+ per command; `src/worktrees/` holds them; `tests/` drives the entry points
5
+ through `subprocess` so env, cwd, both streams and the exit code stay per-test.
6
+
7
+ ```console
8
+ uv sync --all-extras
9
+ uv run --no-sync pytest tests
10
+ uv run --no-sync pre-commit run --all-files
11
+ ```
12
+
13
+ ## Binaries and shell functions are not interchangeable
14
+
15
+ A shell function of a given name beats a binary of that name in bash, zsh and
16
+ fish, and in fish it beats it in `fish -c` too, where the function is autoloaded
17
+ from `functions/`. So a command that exists as both runs different code from a
18
+ prompt and from a script unless the function is a pure adapter.
19
+
20
+ The rule: **a function exists only for a command that must change the caller's
21
+ directory, it carries the same name as the binary, and its body does nothing but
22
+ call `command <name>` and `cd` to what that prints.**
23
+
24
+ | | changes directory | ships as |
25
+ | --- | --- | --- |
26
+ | `gws`, `gwp`, `gwnb`, `gwrot` | no | a console script, and nothing else |
27
+ | a command that lands you somewhere | yes | a console script, plus a function of the same name |
28
+
29
+ A binary cannot `cd` its caller. That is the only thing shell code is here for,
30
+ and any logic that reaches a shell file is logic a script cannot call.
31
+
32
+ Two traps a shim has to handle, both measured:
33
+
34
+ - fish splits command substitution on newlines, so a path containing one
35
+ arrives as two elements. `(command gwa $argv | string collect)` keeps it
36
+ whole. bash and zsh strip only trailing newlines, so `"$(gwa)"` is already
37
+ correct there.
38
+ - that pipeline's `$status` belongs to `string collect`, which returns 1
39
+ whenever it collected nothing, which is the failure case. The command's own
40
+ code is `$pipestatus[1]`.
41
+
42
+ ```fish
43
+ function gwa --wraps gwa
44
+ set -l dest (command gwa $argv | string collect)
45
+ set -l code $pipestatus[1]
46
+ test $code -eq 0; or return $code
47
+ test -n "$dest"; or return 0
48
+ cd -- $dest
49
+ end
50
+ ```
51
+
52
+ `command` is what stops the function calling itself, in all three shells.
53
+
54
+ ## Two commands are alpha
55
+
56
+ `gwnb` and `gwrot` start branches, not worktrees. `origin new-branch` and
57
+ `origin rotate` do the same job, only one of the two sets survives, and
58
+ nothing has decided which. So they match `origin` rather than improve on it:
59
+ the name format, the stem-stripping and the remote-aware collision check are
60
+ copied, and a difference between them is a decision somebody has to make
61
+ later. Say so wherever they are documented.
62
+
63
+ Exit codes are the one deliberate divergence. `origin` has a single non-zero
64
+ code and draws no line between a usage error and a runtime one; this CLI
65
+ keeps argparse's 2 for a usage error.
66
+
67
+ ## Where shell code goes
68
+
69
+ Fisher copies `functions/`, `completions/` and `conf.d/` from the repository
70
+ root and nothing else. Antidote takes a `path:` into the repository and wants a
71
+ `<name>.plugin.zsh` there. So the two live in different places:
72
+
73
+ ```
74
+ functions/gwa.fish fish, installed by Fisher
75
+ completions/gwa.fish
76
+ zsh/plugins/worktrees/worktrees.plugin.zsh zsh, installed by Antidote
77
+ zsh/plugins/worktrees/functions/gwa
78
+ zsh/plugins/worktrees/completions/_gwa
79
+ ```
80
+
81
+ Neither manager puts a binary on `$PATH`. The console scripts arrive through
82
+ `uv tool install` from the published package, which is a separate step.
83
+
84
+ Nothing installs this from a checkout on the user's disk. It is consumed as a
85
+ third-party plugin and a third-party tool, so anything that assumes a local
86
+ clone at a known path is wrong here.
87
+
88
+ A completion asks the CLI for its candidates rather than deriving them. The
89
+ ranking is tested in Python; a shell file that reimplements it is a second
90
+ answer to the same question.
91
+
92
+ ## The picker is Python's, and there is no fzf
93
+
94
+ Building the candidate list, matching a query against it, ranking, deciding
95
+ what each row shows, reading the answer back: all of it in the CLI. No shell
96
+ file holds a candidate, a format string or a rank, and nothing spawns an
97
+ external picker.
98
+
99
+ The set is small. Across the repositories this was written for, the largest
100
+ number of linked worktrees in one is four. A fuzzy finder is the wrong
101
+ instrument at that size, and a numbered prompt reads faster:
102
+
103
+ ```
104
+ 1 fix-parser ~/git/.worktrees/fix-parser/repo
105
+ 2 add-tests ~/git/.worktrees/add-tests/repo
106
+ >
107
+ ```
108
+
109
+ Dropping `fzf` is less code, not more. It removes the spawn, the tty rules
110
+ around it, and the fallback branch a machine without `fzf` would otherwise
111
+ need.
112
+
113
+ Matching is substring first, then subsequence, which is the one idea worth
114
+ taking from `fzf`: the query's letters appearing in order, ranked by how
115
+ tightly they cluster. About twenty lines, `difflib` not required.
116
+
117
+ ```
118
+ 'parse' -> ['fix-parser']
119
+ 'tst' -> ['add-tests', 'try-something']
120
+ 'ruff' -> ['renovate/ruff-0.x']
121
+ ```
122
+
123
+ Two rules, both the shape `gwp`'s prompt already has:
124
+
125
+ - One match takes it outright, with no prompt at all.
126
+ - No terminal, no prompt. Refuse at exit 2 and name the flag that answers
127
+ without one, rather than blocking on something nothing can drive.
128
+
129
+ `--json` and `--list` answer the same question without any of this, and an
130
+ agent uses those.
131
+
132
+ ## Every git command is one spec
133
+
134
+ The spec is the command as you would type it, with `$name` where a value goes.
135
+ The module reads as the list of commands the program can run, and `--explain`
136
+ prints the specs themselves.
137
+
138
+ ```python
139
+ @git("merge-base --is-ancestor $ref $head", ok=(0, 1))
140
+ def is_ancestor(ref, head): ...
141
+
142
+
143
+ @git("--no-optional-locks status --porcelain --ignored=traditional")
144
+ def ignored_paths(): ...
145
+
146
+
147
+ @git("commit-tree $tree -p $parent -m _", env=_SYNTHETIC, ok=(0, 128))
148
+ def commit_tree(tree, parent): ...
149
+ ```
150
+
151
+ `shlex.split` runs once, at decoration time, on the literal spec. Only then is
152
+ each token scanned for a placeholder. That ordering is the whole safety
153
+ property: the splitting is over before any value is seen, so a branch named
154
+ `$(id)`, `a"b` or `has space` lands as exactly one argv element. git accepts
155
+ the first two as branch names, and there is a test that creates them.
156
+
157
+ `$` rather than `{}`, because git's revision syntax is full of braces:
158
+ `^{tree}`, `^{commit}` and `@{upstream}` pass through untouched. `$$` is a
159
+ literal `$`, and so is a `$` with no name after it.
160
+
161
+ Three ways to place a value: `$name` anywhere, including inside a token so
162
+ `--format=$fmt` stays one element; `$*name` splats a list at that position;
163
+ and anything the body returns is appended as a tail, for arguments with no
164
+ fixed place. Names bind from `inspect.signature`, so there is no second
165
+ mapping to keep in step, and a spec naming a parameter the function does not
166
+ have raises `NameError` at import.
167
+
168
+ The form catches its own bugs. `-C $path status --porcelain
169
+ --no-optional-locks` is wrong, because `--no-optional-locks` is a git global
170
+ and belongs before the subcommand. That is visible in a spec and invisible in
171
+ a tuple.
172
+
173
+ A command's name says what the caller gets, echoing the git command where the
174
+ two can agree. `worktree_list`, `worktree_prune` and `worktree_remove` read as
175
+ the subcommands they are; `is_ancestor` beats `merge_base`, because
176
+ `merge-base --is-ancestor` answers a yes/no question and that answer is what
177
+ the caller wants.
178
+
179
+ A spec with `ok=` wide enough to swallow an error is a spec that has to be
180
+ tested on what it returns rather than on its exit code. `upstream_of` takes
181
+ `ok=(0, 1, 128)`, so a typo in one of its flags exits 128, reads as "no
182
+ upstream", and turns every branch into `unknown` with nothing failing.
183
+
184
+ `guard()` runs on the resolved argv inside the wrapper, not at declaration, so
185
+ no call site can assemble its way past it. `reset --hard`, a forced `checkout`
186
+ or `switch`, `clean -f`, a bare `push --force`, `worktree remove --force` and
187
+ `branch -D` are refused absolutely. There is no flag, and `--yes` least of all.
188
+
189
+ `verdicts.py` declares no mutating command. `prune.py` declares the six that
190
+ mutate. A read-only command may not call one: a test reads the verbose log and
191
+ asserts it.
192
+
193
+ ## Content cannot settle a stacked branch
194
+
195
+ `merged_reason` answers by content: an ancestor, or a patch already upstream.
196
+ That works for a branch squashed whole into one commit and fails for one
197
+ merged as part of a stack, where the changes arrive across several squashes
198
+ and the branch keeps an intermediate state the head branch edited further.
199
+
200
+ Measured on this repository's own stack: `rotate` changed `README.md` by
201
+ +54/-5 from the merge base and the head branch by +96/-6 over the same
202
+ regions. `git merge-tree` reports a conflict, and a diff cannot tell that
203
+ from real work left over. There is no content probe that answers this.
204
+
205
+ So the forge answers it, asked last because it costs a round trip and only
206
+ where git already failed. A merged request is cross-checked against
207
+ `unpushed_count`: it speaks for what reached it, never for commits nobody
208
+ pushed, and no upstream at all stays `unknown` rather than becoming zero.
209
+ `--no-forge` keeps a run offline.
210
+
211
+ ## Three verdicts, and the third is not a softer second
212
+
213
+ `remove` was proved finished, `keep` has a reason not to be, and `unknown` could
214
+ not tell. "No upstream, so nothing says whether this was pushed" is a different
215
+ fact from "not merged", and printing them the same way invites somebody to act
216
+ on the wrong one. `prune.removable()` returns the `remove` set and both commands
217
+ call it, so what one prints and the other removes cannot drift.
218
+
219
+ ## Reproduce a failure before asserting its fix
220
+
221
+ Two holes in git are the reason this exists: a squash merge inverts
222
+ `git branch -d`, and `git worktree remove` deletes ignored files at exit 0
223
+ without a word. Each has a test that reproduces the raw git behaviour and a
224
+ test that asserts the fix. A suite that only asserts the fix passes on unfixed
225
+ code forever.
226
+
227
+ The fixture needs no global git config, no signing key, no forge and no
228
+ network. `GIT_CONFIG_GLOBAL` carries that: without it git still reads
229
+ `$HOME/.gitconfig`, and setting `HOME` alone still leaves
230
+ `$XDG_CONFIG_HOME/git/config`.