git-worktrees 0.1.0__py3-none-any.whl

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.
worktrees/pick.py ADDED
@@ -0,0 +1,105 @@
1
+ """Choosing one worktree out of the handful this repository has.
2
+
3
+ No fzf. The largest number of linked worktrees in one repository here is
4
+ four, and at that size a numbered prompt reads faster than a fuzzy finder
5
+ and costs no dependency, no spawn, no tty rules and no absent-fzf fallback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from collections.abc import Callable, Sequence
12
+
13
+ from .git import Refused
14
+ from .repo import Worktree
15
+
16
+
17
+ def subsequence(query: str, text: str) -> tuple[int, int] | None:
18
+ """The one idea worth taking from fzf: the query's letters in order.
19
+
20
+ Returns how tightly they cluster and where they start, so `tst` finds
21
+ `add-tests`. None when they do not all appear.
22
+ """
23
+ q, t = query.lower(), text.lower()
24
+ first: int | None = None
25
+ last = seen = 0
26
+ for i, ch in enumerate(t):
27
+ if seen < len(q) and ch == q[seen]:
28
+ if first is None:
29
+ first = i
30
+ last, seen = i, seen + 1
31
+ if seen < len(q) or first is None:
32
+ return None
33
+ return last - first, first
34
+
35
+
36
+ def matches(query: str, worktrees: Sequence[Worktree]) -> list[Worktree]:
37
+ """Substring first, then subsequence, each group in its own order.
38
+
39
+ A substring hit always beats a subsequence one, so typing more of a name
40
+ never moves it down the list.
41
+
42
+ Substring looks at the branch and the path; subsequence looks at the
43
+ branch alone. Every path here contains `.worktrees`, which supplies a
44
+ `t`, a `w` and an `o` to any query that wants them, so a loose match
45
+ against the path finds everything and means nothing.
46
+ """
47
+ if not query:
48
+ return list(worktrees)
49
+ q = query.lower()
50
+ exact: list[tuple[int, Worktree]] = []
51
+ loose: list[tuple[int, Worktree]] = []
52
+ for wt in worktrees:
53
+ label, path = wt.label.lower(), wt.path.lower()
54
+ if q in label:
55
+ exact.append((label.index(q), wt))
56
+ continue
57
+ if q in path:
58
+ exact.append((len(label) + path.index(q), wt))
59
+ continue
60
+ rank = subsequence(query, wt.label)
61
+ if rank is not None:
62
+ loose.append((rank[0], wt))
63
+ return [wt for _, wt in exact] + [wt for _, wt in loose]
64
+
65
+
66
+ def choose(
67
+ candidates: Sequence[Worktree],
68
+ show: Callable[[str], None],
69
+ ask: Callable[[str], str] | None = None,
70
+ ) -> Worktree | None:
71
+ """One match takes it outright. Otherwise ask, and None means cancelled.
72
+
73
+ A run whose stdin is not a terminal is refused rather than left to block:
74
+ an agent or a pipe reaching a prompt would hang, and --json answers the
75
+ same question without one.
76
+ """
77
+ if not candidates:
78
+ return None
79
+ if len(candidates) == 1:
80
+ return candidates[0]
81
+
82
+ if ask is None:
83
+ if not sys.stdin.isatty():
84
+ raise Refused(
85
+ f"{len(candidates)} worktrees match and this is not a terminal; "
86
+ "narrow the query, or use --list or --json"
87
+ )
88
+ ask = _prompt
89
+
90
+ width = max(len(wt.label) for wt in candidates)
91
+ for i, wt in enumerate(candidates, 1):
92
+ show(f"{i:>3} {wt.label:<{width}} {wt.path}")
93
+ answer = ask(f"which? [1-{len(candidates)}, or blank to cancel] ").strip()
94
+ if not answer:
95
+ return None
96
+ if not answer.isdigit() or not 1 <= int(answer) <= len(candidates):
97
+ raise Refused(f"{answer} is not one of 1 to {len(candidates)}")
98
+ return candidates[int(answer) - 1]
99
+
100
+
101
+ def _prompt(text: str) -> str:
102
+ """The question on stderr, the answer from stdin, so stdout stays data."""
103
+ sys.stderr.write(text)
104
+ sys.stderr.flush()
105
+ return sys.stdin.readline()
worktrees/prune.py ADDED
@@ -0,0 +1,170 @@
1
+ """Removing the worktrees `status` marked `remove`, and nothing else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+
10
+ from . import repo as R
11
+ from .git import GitError, Refused, git
12
+ from .verdicts import REMOVE, Verdict
13
+
14
+
15
+ @git("worktree prune", mutates=True)
16
+ def worktree_prune() -> None:
17
+ """Clear git's bookkeeping for worktrees somebody deleted by hand."""
18
+
19
+
20
+ @git("worktree remove -- $path", mutates=True)
21
+ def worktree_remove(path: str) -> None:
22
+ """Drop a checkout. Refuses on its own when the worktree is dirty."""
23
+
24
+
25
+ @git("branch -d -- $branch", mutates=True)
26
+ def branch_delete(branch: str) -> None:
27
+ """-d, never -D: git's own proof of merge is the only proof accepted."""
28
+
29
+
30
+ @git("rev-parse --verify $ref", ok=(0, 128))
31
+ def sha_of(ref: str) -> None:
32
+ """The sha a ref names, for the line that puts it back."""
33
+
34
+
35
+ @git("cat-file -t $sha", ok=(0, 128))
36
+ def object_type(sha: str) -> None:
37
+ """What kind of object a sha is. Every printed sha must be a commit."""
38
+
39
+
40
+ def removable(verdicts: list[Verdict]) -> list[Verdict]:
41
+ """The `remove` rows, which is the whole of what prune may touch.
42
+
43
+ The one place the set is derived. `status` prints these as `remove` and
44
+ `prune` removes these, so the two cannot drift.
45
+ """
46
+ return [v for v in verdicts if v.verdict == REMOVE]
47
+
48
+
49
+ def restore_line(branch: str, repo: str | os.PathLike[str] | None = None) -> str:
50
+ """The command that puts a branch back, or '' when no sha can be proved.
51
+
52
+ The sha is checked to be a commit. The bug's signature is a correctly
53
+ shaped restore line carrying the wrong sha, so the check is on the object
54
+ and not on the sentence.
55
+ """
56
+ if not branch:
57
+ return ""
58
+ sha = sha_of(f"refs/heads/{branch}", repo=repo)
59
+ text = sha.out.strip() if sha else ""
60
+ if not text:
61
+ return ""
62
+ kind = object_type(text, repo=repo)
63
+ if not kind or kind.out.strip() != "commit":
64
+ return ""
65
+ return f"git branch {branch} {text}"
66
+
67
+
68
+ def plan(
69
+ go: list[Verdict],
70
+ delete_ignored: bool,
71
+ say: Callable[[str], None],
72
+ repo: str | os.PathLike[str] | None = None,
73
+ ) -> None:
74
+ """Print what would go, and what puts it back, before anything does.
75
+
76
+ --quiet and --yes do not silence this.
77
+ """
78
+ for v in go:
79
+ say(f"{v.label} {v.path}")
80
+ restore = restore_line(v.branch, repo=repo)
81
+ if restore:
82
+ say(f" restore with: {restore}")
83
+ if delete_ignored:
84
+ for path in R.ignored_paths(v.path):
85
+ say(f" deleting ignored, unrecoverable: {path}")
86
+
87
+
88
+ def _prompt(text: str) -> str:
89
+ """The question on stderr, the answer from stdin.
90
+
91
+ Not `input`, which puts its prompt on stdout: stdout carries the result
92
+ and nothing else, so `--json` stays parseable in every mode.
93
+ """
94
+ sys.stderr.write(text)
95
+ sys.stderr.flush()
96
+ return sys.stdin.readline()
97
+
98
+
99
+ def confirm(count: int, ask: Callable[[str], str] | None = None) -> bool:
100
+ """Ask before removing. Anything but yes is no.
101
+
102
+ A run whose stdin is not a terminal cannot answer, so it is refused
103
+ rather than left to block: an agent or a pipe reaches this and would
104
+ otherwise hang forever holding the repository's worktrees.
105
+ """
106
+ if ask is None:
107
+ if not sys.stdin.isatty():
108
+ raise Refused(
109
+ f"not a terminal, so nothing can answer for the {count} above; "
110
+ "pass --yes to remove them"
111
+ )
112
+ ask = _prompt
113
+ return ask(f"remove {count} worktree(s)? [y/N] ").strip().lower() in ("y", "yes")
114
+
115
+
116
+ def sweep(
117
+ go: list[Verdict],
118
+ delete_ignored: bool,
119
+ say: Callable[[str], None],
120
+ repo: str | os.PathLike[str] | None = None,
121
+ ) -> int:
122
+ """Remove each one, in order. Returns how many failed.
123
+
124
+ Serial because `worktree remove` and `branch -d` take the repository's
125
+ shared refs and its worktrees/ directory. One that git refuses is not a
126
+ reason to abandon the rest.
127
+ """
128
+ main = R.main_worktree(repo)
129
+ failed = 0
130
+ for v in go:
131
+ try:
132
+ worktree_remove(v.path, repo=repo)
133
+ except (GitError, Refused) as exc:
134
+ say(f"{v.label} kept: {exc}")
135
+ failed += 1
136
+ continue
137
+
138
+ _prune_empty_parents(Path(v.path).parent, main)
139
+
140
+ if v.branch:
141
+ try:
142
+ # -d, so git's own proof of merge decides. A squash-merged
143
+ # branch is refused here and kept: the checkout goes, the
144
+ # branch stays, and the restore line is not needed.
145
+ branch_delete(v.branch, repo=repo)
146
+ except (GitError, Refused) as exc:
147
+ say(f"branch {v.branch} kept: {exc}")
148
+
149
+ worktree_prune(repo=repo)
150
+ return failed
151
+
152
+
153
+ def _prune_empty_parents(start: Path, main: str) -> None:
154
+ """Remove the directories that removing a worktree left empty.
155
+
156
+ Up to the worktrees root and never past it. rmdir refuses a directory
157
+ holding anything, which is the whole guard.
158
+ """
159
+ if not main:
160
+ return
161
+ root = Path(main).parent / ".worktrees"
162
+ d = start
163
+ while d == root or root in d.parents:
164
+ try:
165
+ d.rmdir()
166
+ except OSError:
167
+ return
168
+ if d == root:
169
+ return
170
+ d = d.parent
worktrees/repo.py ADDED
@@ -0,0 +1,323 @@
1
+ """What this repository is: its worktrees, its remote, its head branch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .git import git
10
+
11
+ # --------------------------------------------------------------------------
12
+ # the git calls
13
+ # --------------------------------------------------------------------------
14
+
15
+
16
+ @git("worktree list --porcelain -z")
17
+ def worktree_list() -> None:
18
+ """Every worktree, NUL-delimited."""
19
+
20
+
21
+ @git("remote")
22
+ def remotes() -> None:
23
+ """The names of every remote."""
24
+
25
+
26
+ @git("config --get $key", ok=(0, 1))
27
+ def config_get(key: str) -> None:
28
+ """One config value. Exit 1 means unset, not broken."""
29
+
30
+
31
+ @git("symbolic-ref --quiet $ref", ok=(0, 1, 128))
32
+ def symbolic_ref(ref: str) -> None:
33
+ """What a symbolic ref points at."""
34
+
35
+
36
+ @git("symbolic-ref --quiet --short HEAD", ok=(0, 1, 128))
37
+ def current_branch() -> None:
38
+ """The checked-out branch, short. Exit 1 on a detached HEAD."""
39
+
40
+
41
+ @git("show-ref --verify --quiet $ref", ok=(0, 1))
42
+ def ref_exists(ref: str) -> None:
43
+ """Does this exact ref exist?"""
44
+
45
+
46
+ @git("for-each-ref --count=1 --format=%(refname) $prefix")
47
+ def first_ref_under(prefix: str) -> None:
48
+ """One ref under a prefix, or nothing."""
49
+
50
+
51
+ @git("for-each-ref --count=1 --contains $sha")
52
+ def refs_containing(sha: str) -> None:
53
+ """One ref reaching this commit, or nothing."""
54
+
55
+
56
+ # @{upstream} survives the spec: braces are git's revision syntax, not a
57
+ # placeholder, which is why placeholders are spelled with $.
58
+ @git("rev-parse --abbrev-ref --symbolic-full-name $branch@{upstream}", ok=(0, 1, 128))
59
+ def upstream_of(branch: str) -> None:
60
+ """The upstream a branch tracks. Failure means unknown, not none."""
61
+
62
+
63
+ @git("rev-list --count $a..$b", ok=(0, 128))
64
+ def count_between(a: str, b: str) -> None:
65
+ """How many commits b has that a does not."""
66
+
67
+
68
+ @git("rev-parse --show-toplevel", ok=(0, 128))
69
+ def toplevel() -> None:
70
+ """The root of the worktree we stand in."""
71
+
72
+
73
+ @git("rev-parse --path-format=absolute --git-common-dir", ok=(0, 128))
74
+ def common_dir() -> None:
75
+ """The repository directory every worktree of it shares."""
76
+
77
+
78
+ # --no-optional-locks is a git global, so it goes before the subcommand, which
79
+ # a spec shows and a tuple hides. Without it a listing writes another
80
+ # worktree's index and contends with a `git add` there.
81
+ @git("--no-optional-locks status --porcelain")
82
+ def status_porcelain() -> None:
83
+ """Tracked and untracked changes, one line each."""
84
+
85
+
86
+ # --ignored=traditional collapses an ignored directory into one entry, so
87
+ # node_modules/ is one line rather than forty thousand.
88
+ @git("--no-optional-locks status --porcelain --ignored=traditional")
89
+ def status_with_ignored() -> None:
90
+ """The same, plus the gitignored paths git otherwise never mentions."""
91
+
92
+
93
+ @git("fetch --prune $remote", mutates=True)
94
+ def fetch(remote: str) -> None:
95
+ """Refresh every remote-tracking ref, dropping the ones that are gone."""
96
+
97
+
98
+ @git("remote set-head $remote --auto", mutates=True)
99
+ def set_head_auto(remote: str) -> None:
100
+ """Ask the server which branch it serves by default."""
101
+
102
+
103
+ # --------------------------------------------------------------------------
104
+ # worktree records
105
+ # --------------------------------------------------------------------------
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Worktree:
110
+ path: str
111
+ sha: str
112
+ branch: str # empty when detached or bare
113
+ flags: frozenset[str] # any of bare, detached, locked, prunable
114
+
115
+ @property
116
+ def label(self) -> str:
117
+ return self.branch or "(detached)"
118
+
119
+
120
+ def worktrees(repo: str | os.PathLike[str] | None = None) -> list[Worktree]:
121
+ """Every worktree of this repository, main checkout first.
122
+
123
+ `git worktree list --porcelain -z` NUL-terminates each attribute and ends
124
+ a record with an empty one. Without -z git separates attributes by
125
+ newline, so a directory name holding one parses into two worktrees,
126
+ neither of which exists.
127
+ """
128
+ out = worktree_list(repo=repo).out
129
+ found: list[Worktree] = []
130
+ seen: set[str] = set()
131
+ path = sha = branch = ""
132
+ flags: set[str] = set()
133
+
134
+ def emit() -> None:
135
+ nonlocal path, sha, branch, flags
136
+ if path:
137
+ # The same directory under two spellings is still one worktree.
138
+ key = str(Path(path).resolve())
139
+ if key not in seen:
140
+ seen.add(key)
141
+ found.append(Worktree(path, sha, branch, frozenset(flags)))
142
+ path = sha = branch = ""
143
+ flags = set()
144
+
145
+ for attr in out.split("\0"):
146
+ if attr.startswith("worktree "):
147
+ emit()
148
+ path = attr[len("worktree ") :]
149
+ elif attr.startswith("HEAD "):
150
+ sha = attr[len("HEAD ") :]
151
+ elif attr.startswith("branch "):
152
+ branch = attr[len("branch ") :].removeprefix("refs/heads/")
153
+ elif attr in ("bare", "detached"):
154
+ flags.add(attr)
155
+ elif attr.startswith("locked"):
156
+ flags.add("locked")
157
+ elif attr.startswith("prunable"):
158
+ flags.add("prunable")
159
+ emit()
160
+ return found
161
+
162
+
163
+ def main_worktree(repo: str | os.PathLike[str] | None = None) -> str:
164
+ """The original checkout. git lists it first."""
165
+ records = worktrees(repo)
166
+ return records[0].path if records else ""
167
+
168
+
169
+ # --------------------------------------------------------------------------
170
+ # refs
171
+ # --------------------------------------------------------------------------
172
+
173
+
174
+ def full_ref(name: str, repo: str | os.PathLike[str] | None = None) -> str:
175
+ """The full ref naming branch `name`.
176
+
177
+ A bare name used as a revision is resolved as a tag before a branch, so a
178
+ tag called `origin/main` is what `origin/main` means to merge-base, cherry
179
+ and ^{tree}. Point that tag at a commit containing an unmerged branch and
180
+ the branch reads as merged: deleted, at exit 0, with nothing to say it
181
+ happened.
182
+
183
+ Remote-tracking first, because that is what a head branch normally is. A
184
+ name with no remote-tracking ref is spelled under refs/heads/ whether or
185
+ not it exists: a name resolving to nothing beats one resolving to a tag.
186
+ """
187
+ if not name:
188
+ return ""
189
+ if ref_exists(f"refs/remotes/{name}", repo=repo):
190
+ return f"refs/remotes/{name}"
191
+ return f"refs/heads/{name}"
192
+
193
+
194
+ def ref_name(ref: str) -> str:
195
+ """The ref as a person says it."""
196
+ return ref.removeprefix("refs/remotes/").removeprefix("refs/heads/")
197
+
198
+
199
+ def remote(repo: str | os.PathLike[str] | None = None) -> str:
200
+ """The remote this repository belongs to, or empty when it has none.
201
+
202
+ `origin` is a convention, not a fact. remote.pushDefault is deliberately
203
+ not a rung: it names where commits go, not where they come from, and the
204
+ two differ in exactly the case that makes the question worth asking, a
205
+ fork you push to and an upstream you branch from.
206
+ """
207
+ names = remotes(repo=repo).lines
208
+ if not names:
209
+ return ""
210
+ if len(names) == 1:
211
+ return names[0]
212
+
213
+ stated = [config_get("checkout.defaultRemote", repo=repo).out.strip()]
214
+ branch = current_branch(repo=repo).out.strip()
215
+ if branch:
216
+ stated.append(config_get(f"branch.{branch}.remote", repo=repo).out.strip())
217
+
218
+ for candidate in stated:
219
+ if candidate and candidate != "." and candidate in names:
220
+ return candidate
221
+ return "origin" if "origin" in names else names[0]
222
+
223
+
224
+ def head_ref(
225
+ remote_name: str,
226
+ online: bool,
227
+ repo: str | os.PathLike[str] | None = None,
228
+ ) -> tuple[str, str]:
229
+ """The full ref this repository branches from, and a warning or ''.
230
+
231
+ Returns a full ref, never `origin/main`: every merge question below uses
232
+ it as a revision, and a bare name resolves to a tag first.
233
+ """
234
+ if remote_name:
235
+ # 1. The repository's own answer, recorded at clone time from what the
236
+ # server advertises, so it tells master from main without guessing.
237
+ # Ignored when it dangles, which is what a server-side rename
238
+ # leaves behind.
239
+ symref = symbolic_ref(f"refs/remotes/{remote_name}/HEAD", repo=repo).out.strip()
240
+ if symref and ref_exists(symref, repo=repo):
241
+ return symref, ""
242
+
243
+ # set-head can only point at a remote-tracking ref, so don't spend a
244
+ # round trip when the repository has none.
245
+ tracking = first_ref_under(f"refs/remotes/{remote_name}", repo=repo).out.strip()
246
+ if online and tracking and set_head_auto(remote_name, repo=repo):
247
+ symref = symbolic_ref(
248
+ f"refs/remotes/{remote_name}/HEAD", repo=repo
249
+ ).out.strip()
250
+ if symref and ref_exists(symref, repo=repo):
251
+ return symref, ""
252
+
253
+ # 2. Conventional names, decisive only when exactly one exists.
254
+ # Remote-tracking first: a repository can have a remote and no
255
+ # refs/remotes at all, because a remote added by hand was never fetched.
256
+ found: list[str] = []
257
+ if remote_name:
258
+ found = [
259
+ f"{remote_name}/{c}"
260
+ for c in ("main", "master", "trunk")
261
+ if ref_exists(f"refs/remotes/{remote_name}/{c}", repo=repo)
262
+ ]
263
+ if not found:
264
+ found = [
265
+ c
266
+ for c in ("main", "master", "trunk")
267
+ if ref_exists(f"refs/heads/{c}", repo=repo)
268
+ ]
269
+ if not found:
270
+ return "", ""
271
+
272
+ # 3. Guess, and be honest that it is one.
273
+ warning = ""
274
+ if len(found) > 1:
275
+ warning = (
276
+ f"{remote_name}/HEAD is unset and " if remote_name else ""
277
+ ) + f"{', '.join(found)} all exist, guessing {found[0]}"
278
+ if remote_name:
279
+ warning += f"; settle it with: git remote set-head {remote_name} --auto"
280
+ return full_ref(found[0], repo=repo), warning
281
+
282
+
283
+ # --------------------------------------------------------------------------
284
+ # per-worktree questions
285
+ # --------------------------------------------------------------------------
286
+
287
+
288
+ def is_dirty(path: str) -> bool:
289
+ """Uncommitted work, which is what makes `git worktree remove` refuse."""
290
+ return bool(status_porcelain(repo=path).out.strip())
291
+
292
+
293
+ def ignored_paths(path: str) -> list[str]:
294
+ """The gitignored paths inside a worktree.
295
+
296
+ `git status --porcelain` does not mention these, so a checkout holding
297
+ .env and node_modules/ reads clean, and `git worktree remove` deletes both
298
+ at exit 0 without --force and without a word. Nothing in git brings them
299
+ back: no ref ever pointed at them.
300
+ """
301
+ return [
302
+ line[3:]
303
+ for line in status_with_ignored(repo=path).lines
304
+ if line.startswith("!! ")
305
+ ]
306
+
307
+
308
+ def unpushed_count(
309
+ branch: str, repo: str | os.PathLike[str] | None = None
310
+ ) -> int | None:
311
+ """Commits the upstream has not got, or None when there is no upstream.
312
+
313
+ None is not zero. Branches made here do not track, so "no upstream" is the
314
+ normal state, and reporting zero would quietly disarm the guard for
315
+ exactly those.
316
+ """
317
+ up = upstream_of(branch, repo=repo)
318
+ if not up or not up.out.strip():
319
+ return None
320
+ counted = count_between(up.out.strip(), f"refs/heads/{branch}", repo=repo)
321
+ if not counted:
322
+ return None
323
+ return int(counted.out.strip() or 0)