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/forge.py ADDED
@@ -0,0 +1,115 @@
1
+ """What the forge says about a branch, when git cannot tell.
2
+
3
+ A branch merged as part of a stack is the case content cannot answer. Its
4
+ changes reach the head branch across several squashes, and the intermediate
5
+ state it holds differs from the final one in the same regions, so a stale
6
+ branch whose work is upstream and a branch with real work left look exactly
7
+ alike to a diff.
8
+
9
+ The forge knows. It is asked last, because it costs a round trip and because
10
+ it can be wrong about work pushed after the merge, which is why the answer is
11
+ cross-checked against the commits an upstream has not got.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from dataclasses import dataclass
21
+
22
+ from .git import options
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Request:
27
+ """A pull or merge request for a branch."""
28
+
29
+ number: int
30
+ state: str # MERGED, CLOSED, OPEN
31
+ noun: str # what that forge calls it
32
+
33
+
34
+ def available() -> str:
35
+ """The forge CLI on PATH, or empty when there is none."""
36
+ for tool in ("gh", "glab"):
37
+ if shutil.which(tool):
38
+ return tool
39
+ return ""
40
+
41
+
42
+ def request_for(
43
+ branch: str,
44
+ repo: str | os.PathLike[str] | None = None,
45
+ ) -> Request | None:
46
+ """The newest request whose head is this branch, or None.
47
+
48
+ None covers every way of not knowing: no CLI, not authenticated, no
49
+ remote, no request. The caller treats that as "the forge said nothing"
50
+ rather than as "no".
51
+ """
52
+ tool = available()
53
+ if not tool:
54
+ return None
55
+
56
+ if tool == "gh":
57
+ argv = [
58
+ "gh",
59
+ "pr",
60
+ "list",
61
+ "--head",
62
+ branch,
63
+ "--state",
64
+ "all",
65
+ "--limit",
66
+ "1",
67
+ "--json",
68
+ "number,state",
69
+ ]
70
+ noun = "pull request"
71
+ else:
72
+ argv = [
73
+ "glab",
74
+ "mr",
75
+ "list",
76
+ "--source-branch",
77
+ branch,
78
+ "--all",
79
+ "--output",
80
+ "json",
81
+ ]
82
+ noun = "merge request"
83
+
84
+ options.log.append(tuple(argv))
85
+ if options.verbose:
86
+ import shlex
87
+ import sys
88
+
89
+ print("+ " + shlex.join(argv), file=sys.stderr)
90
+
91
+ try:
92
+ proc = subprocess.run(
93
+ argv,
94
+ capture_output=True,
95
+ text=True,
96
+ check=False,
97
+ cwd=os.fspath(repo) if repo else None,
98
+ timeout=30,
99
+ )
100
+ except (OSError, subprocess.TimeoutExpired):
101
+ return None
102
+ if proc.returncode != 0:
103
+ return None
104
+
105
+ try:
106
+ rows = json.loads(proc.stdout or "[]")
107
+ except json.JSONDecodeError:
108
+ return None
109
+ if not rows:
110
+ return None
111
+
112
+ row = rows[0]
113
+ number = row.get("number") or row.get("iid") or 0
114
+ state = str(row.get("state", "")).upper()
115
+ return Request(int(number), state, noun)
worktrees/git.py ADDED
@@ -0,0 +1,302 @@
1
+ """Every git command this program can run.
2
+
3
+ A decorated function's spec is the command, written the way you would type it,
4
+ with `$name` where a value goes. Reading a module top to bottom gives the
5
+ complete list of git calls that can be issued, and `worktrees --explain`
6
+ prints it.
7
+
8
+ `shlex.split` runs once, at decoration time, on the literal spec. Only then is
9
+ each token scanned for placeholders. That ordering is the safety property: the
10
+ splitting is already over before any value is seen, so a branch named
11
+ `feat$(touch /tmp/PWNED)`, `a"b` or `has space` lands as exactly one argv
12
+ element. Nothing is ever a shell string.
13
+
14
+ `$` rather than `{}` because git's revision syntax is full of braces:
15
+ `^{tree}`, `^{commit}` and `@{upstream}` pass through a spec untouched.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import inspect
22
+ import os
23
+ import shlex
24
+ import subprocess
25
+ import sys
26
+ from collections.abc import Callable, Iterable, Mapping, Sequence
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Protocol
29
+
30
+ Argv = tuple[str, ...]
31
+
32
+
33
+ class GitError(RuntimeError):
34
+ """git exited with a code the caller did not declare acceptable."""
35
+
36
+
37
+ class Refused(Exception):
38
+ """The guard will not issue this command. No flag reaches past it."""
39
+
40
+
41
+ @dataclass
42
+ class Run:
43
+ """What one git call produced."""
44
+
45
+ code: int
46
+ out: str
47
+ err: str
48
+
49
+ def __bool__(self) -> bool:
50
+ return self.code == 0
51
+
52
+ @property
53
+ def lines(self) -> list[str]:
54
+ return self.out.splitlines()
55
+
56
+
57
+ @dataclass
58
+ class Options:
59
+ """Set once by the CLI, read by every call."""
60
+
61
+ verbose: bool = False
62
+ # Every argv issued, in order. A test asserts on this; nothing else reads
63
+ # it, so it costs a list append.
64
+ log: list[Argv] = field(default_factory=list)
65
+
66
+
67
+ options = Options()
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Command:
72
+ """A decorated git call, for --explain."""
73
+
74
+ name: str
75
+ doc: str
76
+ mutates: bool
77
+ ok: tuple[int, ...]
78
+ shape: Argv
79
+
80
+
81
+ _registry: list[Command] = []
82
+
83
+
84
+ def commands() -> list[Command]:
85
+ """Every git command the program can issue, in declaration order."""
86
+ return list(_registry)
87
+
88
+
89
+ # --------------------------------------------------------------------------
90
+ # the guard
91
+ # --------------------------------------------------------------------------
92
+
93
+ # Each of these destroys work git cannot give back. They are refused
94
+ # absolutely: there is no flag, and --yes least of all. The check runs on the
95
+ # resolved argv inside the wrapper, so a caller cannot assemble one past it.
96
+ _ABSOLUTE = "refusing to run"
97
+
98
+
99
+ def _has(argv: Sequence[str], *flags: str) -> bool:
100
+ return any(a in flags for a in argv)
101
+
102
+
103
+ def _short(argv: Sequence[str], letter: str) -> bool:
104
+ """A bundled short flag: -f matches, and so does -fdx."""
105
+ return any(
106
+ a.startswith("-") and not a.startswith("--") and letter in a[1:] for a in argv
107
+ )
108
+
109
+
110
+ def guard(argv: Sequence[str]) -> None:
111
+ """Raise Refused for a command that can lose work."""
112
+ if not argv:
113
+ raise Refused("empty git command")
114
+ head = argv[0]
115
+ rest = argv[1:]
116
+
117
+ if head == "reset" and _has(argv, "--hard"):
118
+ raise Refused(f"{_ABSOLUTE} `git reset --hard`; it discards the working tree")
119
+ if head in ("checkout", "switch") and (
120
+ _short(rest, "f") or _has(rest, "--force", "--discard-changes")
121
+ ):
122
+ raise Refused(
123
+ f"{_ABSOLUTE} a forced `git {head}`; it discards the working tree"
124
+ )
125
+ if head == "clean" and (_short(rest, "f") or _has(rest, "--force")):
126
+ raise Refused(f"{_ABSOLUTE} `git clean -f`; nothing restores what it deletes")
127
+ if head == "push" and (_short(rest, "f") or _has(rest, "--force")):
128
+ raise Refused(
129
+ f"{_ABSOLUTE} a bare `git push --force`; use --force-with-lease instead"
130
+ )
131
+ if (
132
+ head == "worktree"
133
+ and rest
134
+ and rest[0] == "remove"
135
+ and (_short(rest, "f") or _has(rest, "--force"))
136
+ ):
137
+ raise Refused(
138
+ f"{_ABSOLUTE} `git worktree remove --force`; it takes uncommitted work "
139
+ "and ignored files without a word"
140
+ )
141
+ if head == "branch" and (
142
+ _has(rest, "-D") or (_has(rest, "--delete") and _has(rest, "--force"))
143
+ ):
144
+ raise Refused(
145
+ f"{_ABSOLUTE} `git branch -D`; a branch is deleted on proof of merge "
146
+ "or not at all"
147
+ )
148
+
149
+
150
+ # --------------------------------------------------------------------------
151
+ # the decorator
152
+ # --------------------------------------------------------------------------
153
+
154
+
155
+ class GitCall(Protocol):
156
+ """What a decorated function becomes: its own arguments, plus `repo`."""
157
+
158
+ __name__: str
159
+
160
+ def __call__(
161
+ self, *args: Any, repo: str | os.PathLike[str] | None = None, **kwargs: Any
162
+ ) -> Run: ...
163
+
164
+
165
+ def _placeholders(token: str) -> list[str]:
166
+ """The parameter names a spec token refers to."""
167
+ names, i = [], 0
168
+ while i < len(token):
169
+ if token[i] != "$":
170
+ i += 1
171
+ continue
172
+ if token[i + 1 : i + 2] == "$":
173
+ i += 2
174
+ continue
175
+ j = i + 2 if token[i + 1 : i + 2] == "*" else i + 1
176
+ k = j
177
+ while k < len(token) and (token[k].isalnum() or token[k] == "_"):
178
+ k += 1
179
+ if k > j:
180
+ names.append(token[j:k])
181
+ i = max(k, i + 1)
182
+ return names
183
+
184
+
185
+ def _expand(token: str, bound: Mapping[str, Any]) -> list[str]:
186
+ """One spec token becomes one argv element, or several for a `$*splat`.
187
+
188
+ `$$` is a literal `$`, and so is a `$` with no name after it, so a
189
+ `--format=` string can hold one without meaning a parameter.
190
+ """
191
+ if "$*" in token:
192
+ return [str(v) for v in bound[token[token.index("$*") + 2 :]]]
193
+ out, i = "", 0
194
+ while i < len(token):
195
+ if token[i] != "$":
196
+ out += token[i]
197
+ i += 1
198
+ continue
199
+ if token[i + 1 : i + 2] == "$":
200
+ out += "$"
201
+ i += 2
202
+ continue
203
+ j = i + 1
204
+ while j < len(token) and (token[j].isalnum() or token[j] == "_"):
205
+ j += 1
206
+ if j == i + 1:
207
+ out += "$"
208
+ i += 1
209
+ continue
210
+ out += str(bound[token[i + 1 : j]])
211
+ i = j
212
+ return [out]
213
+
214
+
215
+ def git(
216
+ spec: str,
217
+ *,
218
+ ok: Iterable[int] = (0,),
219
+ mutates: bool = False,
220
+ env: Mapping[str, str] | None = None,
221
+ ) -> Callable[[Callable[..., Any]], GitCall]:
222
+ """Turn a spec into a function that runs it.
223
+
224
+ spec the command as you would type it, with `$name` where a value goes
225
+ ok exit codes that mean an answer rather than a failure
226
+ mutates takes the repository's shared refs, so it runs serially
227
+ env pinned environment, for a command whose output must be reproducible
228
+
229
+ A value goes in three ways. `$name` anywhere, including inside a token, so
230
+ `--format=$fmt` stays one element. `$*name` splats a list at that position.
231
+ Anything the body returns is appended as a tail, for arguments with no
232
+ fixed place.
233
+ """
234
+ if not isinstance(spec, str):
235
+ raise TypeError(
236
+ '@git takes the command as a string: @git("worktree list -z"). '
237
+ f"Got {type(spec).__name__}."
238
+ )
239
+ accept = tuple(ok)
240
+ tokens = shlex.split(spec)
241
+
242
+ def decorate(fn: Callable[..., Any]) -> GitCall:
243
+ signature = inspect.signature(fn)
244
+ # At import, not at the call. A spec naming a parameter the function
245
+ # does not have is a typo, and this is the moment it is cheapest to
246
+ # hear about.
247
+ for token in tokens:
248
+ for name in _placeholders(token):
249
+ if name not in signature.parameters:
250
+ raise NameError(
251
+ f"{fn.__name__}: spec names ${name}, which is not a "
252
+ f"parameter of {fn.__name__}{signature}"
253
+ )
254
+
255
+ @functools.wraps(fn)
256
+ def call(
257
+ *args: Any, repo: str | os.PathLike[str] | None = None, **kwargs: Any
258
+ ) -> Run:
259
+ bound = signature.bind(*args, **kwargs)
260
+ bound.apply_defaults()
261
+ argv: list[str] = []
262
+ for token in tokens:
263
+ argv += _expand(token, bound.arguments)
264
+ tail = fn(*args, **kwargs)
265
+ if tail:
266
+ argv += [str(t) for t in tail]
267
+ guard(argv)
268
+
269
+ full = ["git"]
270
+ if repo is not None:
271
+ full += ["-C", os.fspath(repo)]
272
+ full += argv
273
+
274
+ options.log.append(tuple(full))
275
+ if options.verbose:
276
+ # shlex.join, so the printed line pastes back into a shell.
277
+ print("+ " + shlex.join(full), file=sys.stderr)
278
+ environ = None
279
+ if env is not None:
280
+ environ = {**os.environ, **env}
281
+ proc = subprocess.run(
282
+ full, capture_output=True, text=True, env=environ, check=False
283
+ )
284
+ if proc.returncode not in accept:
285
+ raise GitError(
286
+ f"git {shlex.join(argv)} exited {proc.returncode}: "
287
+ f"{proc.stderr.strip() or '(no output)'}"
288
+ )
289
+ return Run(proc.returncode, proc.stdout, proc.stderr)
290
+
291
+ _registry.append(
292
+ Command(
293
+ name=fn.__name__,
294
+ doc=(fn.__doc__ or "").strip().splitlines()[0] if fn.__doc__ else "",
295
+ mutates=mutates,
296
+ ok=accept,
297
+ shape=tuple(tokens),
298
+ )
299
+ )
300
+ return call
301
+
302
+ return decorate
worktrees/layout.py ADDED
@@ -0,0 +1,52 @@
1
+ """Where a worktree lives.
2
+
3
+ Derived rather than configured, so two independently-invoked tools cannot
4
+ disagree about a location neither can be told: `<PARENT>/.worktrees/<NAME>/
5
+ <REPO>`, where PARENT is the directory holding the main checkout and REPO is
6
+ its name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from . import repo as R
15
+
16
+
17
+ def worktrees_root(main: str) -> Path:
18
+ """The directory every worktree of this repository sits under."""
19
+ return Path(main).parent / ".worktrees"
20
+
21
+
22
+ def destination(name: str, main: str) -> Path:
23
+ """Where the worktree for branch `name` belongs.
24
+
25
+ A branch name may hold slashes, and they become directories, which is why
26
+ the repository name goes last: `feat/oauth` gives
27
+ `.worktrees/feat/oauth/<repo>` rather than colliding with `.worktrees/feat`.
28
+ """
29
+ return worktrees_root(main) / name / Path(main).name
30
+
31
+
32
+ def owner_of(candidate: str, repo: str | os.PathLike[str] | None = None) -> str:
33
+ """The repository owning the checkout at `candidate`, when it is not ours.
34
+
35
+ Empty when we own it, or when there is no repository there at all.
36
+
37
+ git is what answers this. `git worktree list` cannot: a worktree belonging
38
+ to another repository is one this repository has never heard of, so the
39
+ list comes back empty and reads exactly like "nothing is there", which is
40
+ how a `gwa` that should have refused lands you in somebody else's
41
+ checkout.
42
+ """
43
+ theirs = R.common_dir(repo=candidate)
44
+ if not theirs or not theirs.out.strip():
45
+ return ""
46
+ ours = R.common_dir(repo=repo)
47
+ if not ours or not ours.out.strip():
48
+ return ""
49
+ t = Path(theirs.out.strip()).resolve()
50
+ if t == Path(ours.out.strip()).resolve():
51
+ return ""
52
+ return str(t.parent)
worktrees/merged.py ADDED
@@ -0,0 +1,103 @@
1
+ """Is a branch's change already in the head branch?
2
+
3
+ `git branch --merged` answers for the merge git can see. Most branches now end
4
+ in a squash, which rewrites their commits into one, so git sees a branch whose
5
+ commits appear nowhere in the head branch: the same shape as a branch nobody
6
+ ever merged. Reaping on that reading loses work; refusing on it means never
7
+ reaping anything.
8
+
9
+ By hand the two are indistinguishable. `squashed` and `unmerged` both report
10
+ NOT merged and both sit one commit ahead. Opposite correct actions, no signal
11
+ between them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from .git import git
19
+
20
+ # commit-tree fails outright when user.email is unset, and a fixed identity
21
+ # keeps the synthetic commit reproducible. Nothing references it, so the next
22
+ # gc collects it.
23
+ _SYNTHETIC = {
24
+ "GIT_AUTHOR_NAME": "worktrees",
25
+ "GIT_AUTHOR_EMAIL": "worktrees@localhost",
26
+ "GIT_AUTHOR_DATE": "@0 +0000",
27
+ "GIT_COMMITTER_NAME": "worktrees",
28
+ "GIT_COMMITTER_EMAIL": "worktrees@localhost",
29
+ "GIT_COMMITTER_DATE": "@0 +0000",
30
+ }
31
+
32
+
33
+ @git("merge-base --is-ancestor $ref $head", ok=(0, 1))
34
+ def is_ancestor(ref: str, head: str) -> None:
35
+ """Non-zero means 'no', not 'broken'."""
36
+
37
+
38
+ @git("merge-base $a $b", ok=(0, 128))
39
+ def merge_base(a: str, b: str) -> None:
40
+ """The commit two refs last had in common."""
41
+
42
+
43
+ @git("rev-parse $ref^{tree}", ok=(0, 128))
44
+ def tree_of(ref: str) -> None:
45
+ """The tree a ref points at."""
46
+
47
+
48
+ @git("commit-tree $tree -p $parent -m _", env=_SYNTHETIC, ok=(0, 128))
49
+ def commit_tree(tree: str, parent: str) -> None:
50
+ """Replay a tree as one commit on top of a parent."""
51
+
52
+
53
+ @git("cherry $head $synth", ok=(0, 128))
54
+ def cherry(head: str, synth: str) -> None:
55
+ """Compare by patch content, which is what a squash preserves."""
56
+
57
+
58
+ def squash_merged(
59
+ branch: str, head: str, repo: str | os.PathLike[str] | None = None
60
+ ) -> bool:
61
+ """Is the change `branch` makes already in `head`?
62
+
63
+ Replay the branch's tree as a single commit on the merge base and let
64
+ `git cherry` say whether that patch is upstream. A leading '-' means it is.
65
+ """
66
+ ref = f"refs/heads/{branch}"
67
+
68
+ tree = tree_of(ref, repo=repo)
69
+ if not tree or not tree.out.strip():
70
+ return False
71
+
72
+ # A branch leaving the head branch's tree exactly as it found it has
73
+ # nothing left to contribute, whatever its history says.
74
+ head_tree = tree_of(head, repo=repo)
75
+ if head_tree and head_tree.out.strip() == tree.out.strip():
76
+ return True
77
+
78
+ base = merge_base(head, ref, repo=repo)
79
+ if not base or not base.out.strip():
80
+ return False
81
+
82
+ synth = commit_tree(tree.out.strip(), base.out.strip(), repo=repo)
83
+ if not synth or not synth.out.strip():
84
+ return False
85
+
86
+ verdict = cherry(head, synth.out.strip(), repo=repo)
87
+ lines = verdict.lines if verdict else []
88
+ return bool(lines) and lines[0].startswith("-")
89
+
90
+
91
+ def merged_reason(
92
+ branch: str, head: str, repo: str | os.PathLike[str] | None = None
93
+ ) -> str:
94
+ """'merged', 'squash-merged', or '' when neither.
95
+
96
+ refs/heads/ explicitly: a branch name used as a revision resolves to a tag
97
+ first, so a tag and a branch sharing a name would answer about the tag.
98
+ """
99
+ if is_ancestor(f"refs/heads/{branch}", head, repo=repo):
100
+ return "merged"
101
+ if squash_merged(branch, head, repo=repo):
102
+ return "squash-merged"
103
+ return ""
@@ -0,0 +1,79 @@
1
+ """Start a branch off a head branch that was fetched a moment ago.
2
+
3
+ The base is <remote>/<head> as it stands after the fetch, not the local copy
4
+ of it, so the branch is already on top of what the server has and nothing has
5
+ to be rebased afterwards.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+
13
+ from . import repo as R
14
+ from .git import git
15
+
16
+
17
+ @git("check-ref-format --branch $name", ok=(0, 1, 128))
18
+ def check_ref_format(name: str) -> None:
19
+ """Would git accept this as a branch name?"""
20
+
21
+
22
+ @git("rev-parse --short $ref", ok=(0, 128))
23
+ def short_sha(ref: str) -> None:
24
+ """The short sha a ref names."""
25
+
26
+
27
+ @git("switch --create $name --no-track $base", mutates=True)
28
+ def switch_create(name: str, base: str) -> None:
29
+ """--no-track, so the head branch does not become this branch's upstream.
30
+
31
+ A branch off refs/remotes/<remote>/main that tracked it would take it as
32
+ its upstream, and `git push` would target the head branch.
33
+ """
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Started:
38
+ branch: str
39
+ base: str # the full ref
40
+ sha: str
41
+
42
+
43
+ class Refusal(Exception):
44
+ """Why no branch was made. The message is the whole answer."""
45
+
46
+
47
+ def create(
48
+ name: str,
49
+ fetch: bool,
50
+ warn: object = None,
51
+ repo: str | os.PathLike[str] | None = None,
52
+ ) -> Started:
53
+ """Fetch, then branch `name` off the head branch and check it out."""
54
+ if not check_ref_format(name, repo=repo):
55
+ raise Refusal(f"{name} is not a valid branch name")
56
+ if R.ref_exists(f"refs/heads/{name}", repo=repo):
57
+ # Native git, so the message names a command that exists for somebody
58
+ # who installed the package without the shell plugin.
59
+ raise Refusal(f"{name} is already a branch; git switch {name} checks it out")
60
+
61
+ remote = R.remote(repo=repo)
62
+ if fetch and remote and not R.fetch(remote, repo=repo) and callable(warn):
63
+ # An offline machine still gets a branch, off whatever it last saw,
64
+ # and the line at the end names the commit it got.
65
+ warn(f"{remote} could not be fetched; branching from what is already here")
66
+
67
+ base, warning = R.head_ref(remote, online=fetch, repo=repo)
68
+ if warning and callable(warn):
69
+ warn(warning)
70
+ if not base:
71
+ hint = remote or "origin"
72
+ raise Refusal(
73
+ "cannot tell which branch this repository branches from; record it "
74
+ f"with: git remote set-head {hint} --auto"
75
+ )
76
+
77
+ sha = short_sha(base, repo=repo)
78
+ switch_create(name, base, repo=repo)
79
+ return Started(name, base, sha.out.strip() if sha else "")