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.
- git_worktrees-0.1.0/.agents/skills/release/SKILL.md +128 -0
- git_worktrees-0.1.0/.claude/settings.json +5 -0
- git_worktrees-0.1.0/.github/workflows/publish.yml +202 -0
- git_worktrees-0.1.0/.github/workflows/tests.yml +77 -0
- git_worktrees-0.1.0/.gitignore +7 -0
- git_worktrees-0.1.0/.pre-commit-config.yaml +35 -0
- git_worktrees-0.1.0/.python-version +1 -0
- git_worktrees-0.1.0/AGENTS.md +230 -0
- git_worktrees-0.1.0/CHANGELOG.md +70 -0
- git_worktrees-0.1.0/LICENSE +201 -0
- git_worktrees-0.1.0/PKG-INFO +335 -0
- git_worktrees-0.1.0/README.md +310 -0
- git_worktrees-0.1.0/conf.d/worktrees.fish +2 -0
- git_worktrees-0.1.0/docs/DEVELOPMENT.md +214 -0
- git_worktrees-0.1.0/functions/gwa.fish +12 -0
- git_worktrees-0.1.0/functions/gwl.fish +12 -0
- git_worktrees-0.1.0/functions/gwm.fish +12 -0
- git_worktrees-0.1.0/functions/gwr.fish +12 -0
- git_worktrees-0.1.0/pyproject.toml +105 -0
- git_worktrees-0.1.0/renovate.json +11 -0
- git_worktrees-0.1.0/scripts/verify-publish.bash +55 -0
- git_worktrees-0.1.0/src/worktrees/__init__.py +8 -0
- git_worktrees-0.1.0/src/worktrees/cli.py +752 -0
- git_worktrees-0.1.0/src/worktrees/forge.py +115 -0
- git_worktrees-0.1.0/src/worktrees/git.py +302 -0
- git_worktrees-0.1.0/src/worktrees/layout.py +52 -0
- git_worktrees-0.1.0/src/worktrees/merged.py +103 -0
- git_worktrees-0.1.0/src/worktrees/new_branch.py +79 -0
- git_worktrees-0.1.0/src/worktrees/pick.py +105 -0
- git_worktrees-0.1.0/src/worktrees/prune.py +170 -0
- git_worktrees-0.1.0/src/worktrees/repo.py +323 -0
- git_worktrees-0.1.0/src/worktrees/rotate.py +142 -0
- git_worktrees-0.1.0/src/worktrees/verdicts.py +161 -0
- git_worktrees-0.1.0/src/worktrees/worktree.py +221 -0
- git_worktrees-0.1.0/tests/conftest.py +90 -0
- git_worktrees-0.1.0/tests/test_cli.py +319 -0
- git_worktrees-0.1.0/tests/test_forge.py +195 -0
- git_worktrees-0.1.0/tests/test_gates.py +369 -0
- git_worktrees-0.1.0/tests/test_new_branch.py +172 -0
- git_worktrees-0.1.0/tests/test_rotate.py +203 -0
- git_worktrees-0.1.0/tests/test_shims.py +224 -0
- git_worktrees-0.1.0/tests/test_spec.py +145 -0
- git_worktrees-0.1.0/tests/test_version.py +57 -0
- git_worktrees-0.1.0/tests/test_worktree.py +282 -0
- git_worktrees-0.1.0/uv.lock +234 -0
- git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwa +9 -0
- git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwl +9 -0
- git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwm +9 -0
- git_worktrees-0.1.0/zsh/plugins/worktrees/functions/gwr +9 -0
- 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,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,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`.
|