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.
- git_worktrees-0.1.0.dist-info/METADATA +335 -0
- git_worktrees-0.1.0.dist-info/RECORD +18 -0
- git_worktrees-0.1.0.dist-info/WHEEL +4 -0
- git_worktrees-0.1.0.dist-info/entry_points.txt +11 -0
- git_worktrees-0.1.0.dist-info/licenses/LICENSE +201 -0
- worktrees/__init__.py +8 -0
- worktrees/cli.py +752 -0
- worktrees/forge.py +115 -0
- worktrees/git.py +302 -0
- worktrees/layout.py +52 -0
- worktrees/merged.py +103 -0
- worktrees/new_branch.py +79 -0
- worktrees/pick.py +105 -0
- worktrees/prune.py +170 -0
- worktrees/repo.py +323 -0
- worktrees/rotate.py +142 -0
- worktrees/verdicts.py +161 -0
- worktrees/worktree.py +221 -0
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 ""
|
worktrees/new_branch.py
ADDED
|
@@ -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 "")
|