@henryqw/pi-pr 9.0.6 → 9.1.1
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.
- package/LICENSE-APACHE-2.0 +202 -0
- package/NOTICE.md +24 -0
- package/README.md +39 -3
- package/docs/git-workflows-tested.md +119 -0
- package/extensions/git-commit.ts +132 -0
- package/extensions/pr-command.ts +20 -1
- package/extensions/pr-comment-sweep.ts +4 -3
- package/extensions/pr-create.ts +27 -18
- package/extensions/pr-publish-work.ts +10 -15
- package/extensions/pr.ts +75 -5
- package/package.json +7 -3
- package/skills/git-commit/SKILL.md +18 -0
- package/skills/git-pr/SKILL.md +18 -0
- package/skills/pi-pr-fix-ci/SKILL.md +1 -1
- package/skills/update-from-main/SKILL.md +95 -0
- package/skills/update-from-main/scripts/update_from_main.py +752 -0
- package/skills/update-from-main/scripts/validate.py +17 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Derived from HenryQW/skills; modified for exact backup identity, retention, worktree ownership, and regressions.
|
|
4
|
+
# See ../../../NOTICE.md for the source snapshot and modification notices.
|
|
5
|
+
"""Update current non-main worktree branch from fetched origin/main."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from unittest import mock
|
|
22
|
+
|
|
23
|
+
MAIN_SOURCE_REF = "refs/heads/main"
|
|
24
|
+
FETCHED_MAIN_REF = "refs/remotes/origin/main"
|
|
25
|
+
MAIN_BRANCH_REF = "refs/heads/main"
|
|
26
|
+
# Per-worktree refs record backup ownership; other worktrees cannot see them.
|
|
27
|
+
OWNER_PREFIX = "refs/worktree/update-from-main/"
|
|
28
|
+
BACKUP_REF = OWNER_PREFIX + "backup"
|
|
29
|
+
PENDING_PREFIX = OWNER_PREFIX + "pending/"
|
|
30
|
+
# Shared marker: an owner adopted this legacy backup in its worktree.
|
|
31
|
+
ADOPTED_PREFIX = "refs/update-from-main/adopted/"
|
|
32
|
+
# Stash subjects written before worktree ownership existed; they have no recorded owner.
|
|
33
|
+
LEGACY_SUBJECT = re.compile(r": update-from-main(?: [0-9a-f-]{36})?$")
|
|
34
|
+
IN_PROGRESS_PATHS = (
|
|
35
|
+
"MERGE_HEAD",
|
|
36
|
+
"CHERRY_PICK_HEAD",
|
|
37
|
+
"REVERT_HEAD",
|
|
38
|
+
"rebase-apply",
|
|
39
|
+
"rebase-merge",
|
|
40
|
+
"sequencer",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UpdateError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(
|
|
49
|
+
args: list[str], *, cwd: Path | None = None, check: bool = True, input: str | None = None
|
|
50
|
+
) -> subprocess.CompletedProcess[str]:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["git", *args],
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
input=input,
|
|
55
|
+
text=True,
|
|
56
|
+
stdout=subprocess.PIPE,
|
|
57
|
+
stderr=subprocess.PIPE,
|
|
58
|
+
)
|
|
59
|
+
if check and result.returncode:
|
|
60
|
+
detail = (result.stderr or result.stdout).strip()
|
|
61
|
+
raise UpdateError(detail or f"git {' '.join(args)} failed")
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def git(args: list[str], *, cwd: Path | None = None) -> str:
|
|
66
|
+
return run(args, cwd=cwd).stdout.strip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def revision(ref: str) -> str:
|
|
70
|
+
return git(["rev-parse", "--verify", f"{ref}^{{commit}}"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def current_branch_ref() -> str:
|
|
74
|
+
result = run(["symbolic-ref", "--quiet", "HEAD"], check=False)
|
|
75
|
+
if result.returncode:
|
|
76
|
+
raise UpdateError("HEAD must name a local branch")
|
|
77
|
+
ref = result.stdout.strip()
|
|
78
|
+
if ref == MAIN_BRANCH_REF:
|
|
79
|
+
raise UpdateError("refusing to update main from itself")
|
|
80
|
+
if not ref.startswith("refs/heads/"):
|
|
81
|
+
raise UpdateError(f"HEAD must name refs/heads/*, got {ref}")
|
|
82
|
+
return ref
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def has_git_path(name: str) -> bool:
|
|
86
|
+
return Path(git(["rev-parse", "--git-path", name])).exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def active_operation() -> str | None:
|
|
90
|
+
return next((name for name in IN_PROGRESS_PATHS if has_git_path(name)), None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def unmerged_paths() -> list[str]:
|
|
94
|
+
output = run(["diff", "--name-only", "--diff-filter=U", "-z"]).stdout
|
|
95
|
+
return output.removesuffix("\0").split("\0") if output else []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def status_lines() -> list[str]:
|
|
99
|
+
return git(["status", "--porcelain=v1", "--untracked-files=all"]).splitlines()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ignored_source_paths(source_sha: str) -> list[str]:
|
|
103
|
+
root = Path(git(["rev-parse", "--show-toplevel"]))
|
|
104
|
+
ignored = run(["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], cwd=root).stdout.removesuffix("\0").split("\0")
|
|
105
|
+
source = run(["ls-tree", "-r", "--full-tree", "--name-only", "-z", source_sha], cwd=root).stdout.removesuffix("\0").split("\0")
|
|
106
|
+
return sorted(path for path in ignored if path and any(
|
|
107
|
+
path == tracked or path.startswith(tracked + "/") or tracked.startswith(path + "/")
|
|
108
|
+
for tracked in source if tracked
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def require_ready_worktree() -> str:
|
|
113
|
+
if git(["rev-parse", "--is-inside-work-tree"]) != "true":
|
|
114
|
+
raise UpdateError("current directory must be inside a worktree")
|
|
115
|
+
operation = active_operation()
|
|
116
|
+
if operation:
|
|
117
|
+
raise UpdateError(f"Git operation already active: {operation}")
|
|
118
|
+
if unmerged_paths():
|
|
119
|
+
raise UpdateError("worktree has unresolved conflicts")
|
|
120
|
+
branch = current_branch_ref()
|
|
121
|
+
owned = git(["for-each-ref", "--format=%(refname) %(objectname)", OWNER_PREFIX])
|
|
122
|
+
if owned:
|
|
123
|
+
raise UpdateError(
|
|
124
|
+
f"this worktree has unfinished update-from-main recovery: {'; '.join(owned.splitlines())}; "
|
|
125
|
+
"finish and complete it before another run"
|
|
126
|
+
)
|
|
127
|
+
adopted = set(git(["for-each-ref", "--format=%(objectname)", ADOPTED_PREFIX]).splitlines())
|
|
128
|
+
entries = (line.split("\0", 1) for line in git(["stash", "list", "--format=%H%x00%gs"]).splitlines())
|
|
129
|
+
legacy = [oid for oid, subject in entries if LEGACY_SUBJECT.search(subject) and oid not in adopted]
|
|
130
|
+
if legacy:
|
|
131
|
+
raise UpdateError(f"legacy update-from-main backup {legacy[0]} has no recorded owner; its owner must recover or adopt it")
|
|
132
|
+
return branch
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def stash_dirty_worktree(source_sha: str) -> str | None:
|
|
136
|
+
ignored = ignored_source_paths(source_sha)
|
|
137
|
+
if not status_lines() and not ignored:
|
|
138
|
+
return None
|
|
139
|
+
token = str(uuid.uuid4())
|
|
140
|
+
message = f"update-from-main owned {token}"
|
|
141
|
+
pending = PENDING_PREFIX + token
|
|
142
|
+
# Record ownership before the stash can change the worktree.
|
|
143
|
+
run(["update-ref", pending, "HEAD", ""])
|
|
144
|
+
mode = "--all" if ignored else "--include-untracked"
|
|
145
|
+
pushed = run(["stash", "push", mode, "--message", message], check=False)
|
|
146
|
+
# Linked worktrees share the stash stack. Find this run's entry, not its mutable tip.
|
|
147
|
+
entries = git(["stash", "list", "--format=%H%x00%gs"]).splitlines()
|
|
148
|
+
matches = [line.split("\0", 1)[0] for line in entries if line.endswith(f": {message}")]
|
|
149
|
+
detail = (pushed.stderr or pushed.stdout).strip()
|
|
150
|
+
if len(matches) != 1:
|
|
151
|
+
failure = f"{detail}; " if pushed.returncode else ""
|
|
152
|
+
raise UpdateError(f"{failure}could not identify backup with message {message}; {pending} blocks this worktree until recovery")
|
|
153
|
+
run(["update-ref", "--stdin"], input=f"create {BACKUP_REF} {matches[0]}\ndelete {pending}\n")
|
|
154
|
+
if pushed.returncode:
|
|
155
|
+
raise UpdateError(f"{detail}; stash backup retained at {matches[0]}")
|
|
156
|
+
return matches[0]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fetch_source() -> str:
|
|
160
|
+
# Porcelain reports the OID this fetch observed, even if another fetch moves the tracking ref.
|
|
161
|
+
args = ["fetch", "--verbose", "--porcelain", "--no-tags", "--no-write-fetch-head", "origin", f"+{MAIN_SOURCE_REF}:{FETCHED_MAIN_REF}"]
|
|
162
|
+
result = run(args, check=False)
|
|
163
|
+
if result.returncode:
|
|
164
|
+
detail = (result.stderr or result.stdout).strip()
|
|
165
|
+
race = re.search(
|
|
166
|
+
rf"cannot lock ref '{re.escape(FETCHED_MAIN_REF)}': "
|
|
167
|
+
r"is at [0-9a-f]+ but expected [0-9a-f]+\b",
|
|
168
|
+
detail,
|
|
169
|
+
)
|
|
170
|
+
if not race:
|
|
171
|
+
raise UpdateError(detail or "git fetch failed")
|
|
172
|
+
time.sleep(0.2)
|
|
173
|
+
result = run(args)
|
|
174
|
+
matches = [re.fullmatch(r"[ *+=] [0-9a-f]+ ([0-9a-f]{40}|[0-9a-f]{64}) " + re.escape(FETCHED_MAIN_REF), line)
|
|
175
|
+
for line in result.stdout.splitlines()]
|
|
176
|
+
captured = [match.group(1) for match in matches if match]
|
|
177
|
+
if len(captured) != 1:
|
|
178
|
+
raise UpdateError("git fetch returned invalid origin/main evidence")
|
|
179
|
+
oid = captured[0]
|
|
180
|
+
return oid
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_ancestor(ancestor: str, descendant: str) -> bool:
|
|
184
|
+
result = run(["merge-base", "--is-ancestor", ancestor, descendant], check=False)
|
|
185
|
+
if result.returncode == 0:
|
|
186
|
+
return True
|
|
187
|
+
if result.returncode == 1:
|
|
188
|
+
return False
|
|
189
|
+
detail = (result.stderr or result.stdout).strip()
|
|
190
|
+
raise UpdateError(detail or "git merge-base failed")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def restore_stash(stash_oid: str) -> tuple[bool, str, str]:
|
|
194
|
+
result = run(["stash", "apply", "--index", stash_oid], check=False)
|
|
195
|
+
detail = (result.stderr or result.stdout).strip().replace("\n", " | ")
|
|
196
|
+
return result.returncode == 0, "retained", detail
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def emit(
|
|
200
|
+
status: str,
|
|
201
|
+
branch_ref: str,
|
|
202
|
+
head_before: str,
|
|
203
|
+
main_sha: str,
|
|
204
|
+
stash_oid: str | None,
|
|
205
|
+
stash_state: str,
|
|
206
|
+
conflict_paths: list[str] | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
stash = "none" if stash_oid is None else f"{stash_oid}:{stash_state}"
|
|
209
|
+
fields = [
|
|
210
|
+
("status", status),
|
|
211
|
+
("branch", branch_ref),
|
|
212
|
+
("before", head_before),
|
|
213
|
+
("main", f"{FETCHED_MAIN_REF}:{main_sha}"),
|
|
214
|
+
("head", revision("HEAD")),
|
|
215
|
+
("stash", stash),
|
|
216
|
+
]
|
|
217
|
+
if conflict_paths:
|
|
218
|
+
fields.append(("conflicts", str(len(conflict_paths))))
|
|
219
|
+
print(" ".join(f"{key}={value}" for key, value in fields))
|
|
220
|
+
if conflict_paths:
|
|
221
|
+
print(f"conflict_paths={json.dumps(conflict_paths, separators=(',', ':'))}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def update_from_main() -> int:
|
|
225
|
+
stash_oid: str | None = None
|
|
226
|
+
main_sha = "none"
|
|
227
|
+
try:
|
|
228
|
+
branch_ref = require_ready_worktree()
|
|
229
|
+
head_before = revision("HEAD")
|
|
230
|
+
main_sha = fetch_source()
|
|
231
|
+
stash_oid = stash_dirty_worktree(main_sha)
|
|
232
|
+
if status_lines():
|
|
233
|
+
raise UpdateError(f"stash {stash_oid} did not clean worktree")
|
|
234
|
+
if current_branch_ref() != branch_ref or revision(branch_ref) != head_before:
|
|
235
|
+
raise UpdateError("current branch moved before merge")
|
|
236
|
+
if is_ancestor(main_sha, "HEAD"):
|
|
237
|
+
outcome = "up_to_date"
|
|
238
|
+
else:
|
|
239
|
+
result = run(["merge", "--ff", "--no-edit", main_sha], check=False)
|
|
240
|
+
if result.returncode:
|
|
241
|
+
detail = (result.stderr or result.stdout).strip().replace("\n", " | ")
|
|
242
|
+
conflicts = unmerged_paths()
|
|
243
|
+
if conflicts:
|
|
244
|
+
emit(
|
|
245
|
+
"merge_conflict",
|
|
246
|
+
branch_ref,
|
|
247
|
+
head_before,
|
|
248
|
+
main_sha,
|
|
249
|
+
stash_oid,
|
|
250
|
+
"retained",
|
|
251
|
+
conflicts,
|
|
252
|
+
)
|
|
253
|
+
print(f"error: {detail}", file=sys.stderr)
|
|
254
|
+
return 2
|
|
255
|
+
if has_git_path("MERGE_HEAD"):
|
|
256
|
+
emit("merge_pending", branch_ref, head_before, main_sha, stash_oid, "retained")
|
|
257
|
+
print(
|
|
258
|
+
f"error: {detail}; fix hook failure, run GIT_EDITOR=true git merge --continue, "
|
|
259
|
+
"then apply non-none stash OID",
|
|
260
|
+
file=sys.stderr,
|
|
261
|
+
)
|
|
262
|
+
return 2
|
|
263
|
+
raise UpdateError(detail or "git merge failed")
|
|
264
|
+
outcome = "merged"
|
|
265
|
+
|
|
266
|
+
if outcome == "merged" and status_lines():
|
|
267
|
+
emit("submodule_update_required", branch_ref, head_before, main_sha, stash_oid, "retained")
|
|
268
|
+
print(
|
|
269
|
+
"error: initialized submodule requires git submodule update --checkout --recursive; "
|
|
270
|
+
"then apply non-none stash OID",
|
|
271
|
+
file=sys.stderr,
|
|
272
|
+
)
|
|
273
|
+
return 4
|
|
274
|
+
|
|
275
|
+
if stash_oid:
|
|
276
|
+
restored, stash_state, detail = restore_stash(stash_oid)
|
|
277
|
+
if not restored:
|
|
278
|
+
conflicts = unmerged_paths()
|
|
279
|
+
status = "stash_conflict" if conflicts else "stash_restore_failed"
|
|
280
|
+
emit(
|
|
281
|
+
status,
|
|
282
|
+
branch_ref,
|
|
283
|
+
head_before,
|
|
284
|
+
main_sha,
|
|
285
|
+
stash_oid,
|
|
286
|
+
"retained",
|
|
287
|
+
conflicts,
|
|
288
|
+
)
|
|
289
|
+
print(f"error: {detail}", file=sys.stderr)
|
|
290
|
+
return 3
|
|
291
|
+
else:
|
|
292
|
+
stash_state = "none"
|
|
293
|
+
emit(outcome, branch_ref, head_before, main_sha, stash_oid, stash_state)
|
|
294
|
+
return 0
|
|
295
|
+
except UpdateError as error:
|
|
296
|
+
if stash_oid and not active_operation():
|
|
297
|
+
restored, stash_state, detail = restore_stash(stash_oid)
|
|
298
|
+
if not restored:
|
|
299
|
+
error = UpdateError(f"{error}; stash backup retained at {stash_oid}: {detail}")
|
|
300
|
+
elif stash_state == "retained":
|
|
301
|
+
error = UpdateError(f"{error}; stash restored but retained at {stash_oid}")
|
|
302
|
+
elif stash_oid:
|
|
303
|
+
error = UpdateError(f"{error}; stash backup retained at {stash_oid}")
|
|
304
|
+
print(f"error: {error}", file=sys.stderr)
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_git(path: Path, *args: str) -> str:
|
|
309
|
+
return git(list(args), cwd=path)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def setup_repo(root: Path) -> tuple[Path, Path]:
|
|
313
|
+
root.mkdir()
|
|
314
|
+
origin = root / "origin.git"
|
|
315
|
+
seed = root / "seed"
|
|
316
|
+
repo = root / "repo"
|
|
317
|
+
run(["init", "--bare", os.fspath(origin)])
|
|
318
|
+
run(["init", os.fspath(seed)])
|
|
319
|
+
test_git(seed, "checkout", "-b", "main")
|
|
320
|
+
test_git(seed, "config", "user.email", "agent@example.invalid")
|
|
321
|
+
test_git(seed, "config", "user.name", "Agent")
|
|
322
|
+
(seed / "shared.txt").write_text("base\n", encoding="utf-8")
|
|
323
|
+
(seed / "conflict.txt").write_text("base\n", encoding="utf-8")
|
|
324
|
+
test_git(seed, "add", ".")
|
|
325
|
+
test_git(seed, "commit", "-m", "init")
|
|
326
|
+
test_git(seed, "remote", "add", "origin", os.fspath(origin))
|
|
327
|
+
test_git(seed, "push", "-u", "origin", "main")
|
|
328
|
+
run(["--git-dir", os.fspath(origin), "symbolic-ref", "HEAD", MAIN_BRANCH_REF])
|
|
329
|
+
run(["clone", os.fspath(origin), os.fspath(repo)])
|
|
330
|
+
test_git(repo, "config", "user.email", "agent@example.invalid")
|
|
331
|
+
test_git(repo, "config", "user.name", "Agent")
|
|
332
|
+
test_git(repo, "checkout", "-b", "feature")
|
|
333
|
+
return seed, repo
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def commit(path: Path, name: str, value: str, message: str) -> str:
|
|
337
|
+
(path / name).write_text(value, encoding="utf-8")
|
|
338
|
+
test_git(path, "add", name)
|
|
339
|
+
test_git(path, "commit", "-m", message)
|
|
340
|
+
return test_git(path, "rev-parse", "HEAD")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def update_in(path: Path) -> tuple[int, str]:
|
|
344
|
+
previous = Path.cwd()
|
|
345
|
+
output = io.StringIO()
|
|
346
|
+
try:
|
|
347
|
+
os.chdir(path)
|
|
348
|
+
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
|
|
349
|
+
return update_from_main(), output.getvalue()
|
|
350
|
+
finally:
|
|
351
|
+
os.chdir(previous)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def self_test() -> None:
|
|
355
|
+
lock_race = subprocess.CompletedProcess(
|
|
356
|
+
["git"],
|
|
357
|
+
1,
|
|
358
|
+
"",
|
|
359
|
+
f"error: cannot lock ref '{FETCHED_MAIN_REF}': is at abc but expected def",
|
|
360
|
+
)
|
|
361
|
+
oid = "a" * 40
|
|
362
|
+
success = subprocess.CompletedProcess(["git"], 0, f"= {oid} {oid} {FETCHED_MAIN_REF}\n", "")
|
|
363
|
+
fetch_args = ["fetch", "--verbose", "--porcelain", "--no-tags", "--no-write-fetch-head", "origin", f"+{MAIN_SOURCE_REF}:{FETCHED_MAIN_REF}"]
|
|
364
|
+
with mock.patch(__name__ + ".run", side_effect=(lock_race, success)) as mocked_run:
|
|
365
|
+
with mock.patch(__name__ + ".time.sleep") as mocked_sleep:
|
|
366
|
+
assert fetch_source() == oid
|
|
367
|
+
assert mocked_run.call_args_list == [
|
|
368
|
+
mock.call(fetch_args, check=False),
|
|
369
|
+
mock.call(fetch_args),
|
|
370
|
+
]
|
|
371
|
+
mocked_sleep.assert_called_once_with(0.2)
|
|
372
|
+
|
|
373
|
+
non_races = (
|
|
374
|
+
"error: network down",
|
|
375
|
+
f"error: cannot lock ref '{FETCHED_MAIN_REF}': expected symref but is a regular ref",
|
|
376
|
+
)
|
|
377
|
+
for detail in non_races:
|
|
378
|
+
failure = subprocess.CompletedProcess(["git"], 1, "", detail)
|
|
379
|
+
with mock.patch(__name__ + ".run", return_value=failure) as mocked_run:
|
|
380
|
+
try:
|
|
381
|
+
fetch_source()
|
|
382
|
+
except UpdateError as error:
|
|
383
|
+
assert str(error) == detail
|
|
384
|
+
else:
|
|
385
|
+
raise AssertionError("non-race fetch failure did not fail fast")
|
|
386
|
+
assert mocked_run.call_count == 1
|
|
387
|
+
|
|
388
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
389
|
+
root = Path(raw_tmp)
|
|
390
|
+
seed, repo = setup_repo(root / "clean")
|
|
391
|
+
head_before = commit(repo, "feature.txt", "feature\n", "test: feature")
|
|
392
|
+
main_sha = commit(seed, "main.txt", "main\n", "test: main")
|
|
393
|
+
test_git(seed, "push", "origin", "main")
|
|
394
|
+
(repo / "staged.txt").write_text("staged\n", encoding="utf-8")
|
|
395
|
+
test_git(repo, "add", "staged.txt")
|
|
396
|
+
(repo / "untracked.txt").write_text("untracked\n", encoding="utf-8")
|
|
397
|
+
result, output = update_in(repo)
|
|
398
|
+
assert result == 0
|
|
399
|
+
assert len(output.split()) == 6
|
|
400
|
+
assert "branch=refs/heads/feature" in output
|
|
401
|
+
assert f"before={head_before}" in output
|
|
402
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
403
|
+
assert f"head={test_git(repo, 'rev-parse', 'HEAD')}" in output
|
|
404
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
405
|
+
assert f"stash={stash_oid}:retained" in output
|
|
406
|
+
assert test_git(repo, "rev-parse", FETCHED_MAIN_REF) == main_sha
|
|
407
|
+
assert (repo / "main.txt").read_text(encoding="utf-8") == "main\n"
|
|
408
|
+
assert "A staged.txt" in test_git(repo, "status", "--porcelain=v1", "--untracked-files=all")
|
|
409
|
+
assert (repo / "untracked.txt").read_text(encoding="utf-8") == "untracked\n"
|
|
410
|
+
peer = root / "clean-peer"
|
|
411
|
+
test_git(repo, "worktree", "add", "-b", "peer", os.fspath(peer), "HEAD")
|
|
412
|
+
(peer / "peer.txt").write_text("peer\n", encoding="utf-8")
|
|
413
|
+
result, output = update_in(peer) # Another worktree's retained backup does not block this one.
|
|
414
|
+
assert result == 0, output
|
|
415
|
+
peer_oid = re.search(r"stash=([0-9a-f]+):retained", output).group(1)
|
|
416
|
+
assert peer_oid != stash_oid and test_git(peer, "rev-parse", BACKUP_REF) == peer_oid
|
|
417
|
+
assert (peer / "peer.txt").read_text(encoding="utf-8") == "peer\n"
|
|
418
|
+
assert test_git(repo, "rev-parse", BACKUP_REF) == stash_oid
|
|
419
|
+
assert update_in(repo)[0] == 1 # Even a restored backup needs explicit user completion.
|
|
420
|
+
test_git(repo, "checkout", "-b", "switched")
|
|
421
|
+
assert update_in(repo)[0] == 1 # Ownership follows the worktree, not its branch.
|
|
422
|
+
assert test_git(repo, "rev-parse", BACKUP_REF) == stash_oid
|
|
423
|
+
|
|
424
|
+
_seed, repo = setup_repo(root / "interrupted")
|
|
425
|
+
(repo / "shared.txt").write_text("staged\n", encoding="utf-8")
|
|
426
|
+
test_git(repo, "add", "shared.txt")
|
|
427
|
+
(repo / "shared.txt").write_text("unstaged\n", encoding="utf-8")
|
|
428
|
+
(repo / "local.txt").write_bytes(b"local\x00bytes\n")
|
|
429
|
+
real_run = run
|
|
430
|
+
|
|
431
|
+
def interrupt_after_push(args, **kwargs):
|
|
432
|
+
result = real_run(args, **kwargs)
|
|
433
|
+
if args[:2] == ["stash", "push"]:
|
|
434
|
+
raise KeyboardInterrupt
|
|
435
|
+
return result
|
|
436
|
+
|
|
437
|
+
with mock.patch(__name__ + ".run", side_effect=interrupt_after_push):
|
|
438
|
+
try:
|
|
439
|
+
update_in(repo)
|
|
440
|
+
except KeyboardInterrupt:
|
|
441
|
+
pass
|
|
442
|
+
else:
|
|
443
|
+
raise AssertionError("interruption did not propagate")
|
|
444
|
+
pending = test_git(repo, "for-each-ref", "--format=%(refname)", PENDING_PREFIX)
|
|
445
|
+
assert len(pending.splitlines()) == 1 and not (repo / "local.txt").exists()
|
|
446
|
+
assert update_in(repo)[0] == 1 # Uncaptured backup blocks until recovered.
|
|
447
|
+
assert test_git(repo, "for-each-ref", "--format=%(refname)", PENDING_PREFIX) == pending
|
|
448
|
+
token = pending.removeprefix(PENDING_PREFIX)
|
|
449
|
+
entries = test_git(repo, "stash", "list", "--format=%H %gs").splitlines()
|
|
450
|
+
(stash_oid,) = [line.split()[0] for line in entries if line.endswith(f": update-from-main owned {token}")]
|
|
451
|
+
test_git(repo, "update-ref", BACKUP_REF, stash_oid, "")
|
|
452
|
+
test_git(repo, "update-ref", "-d", pending)
|
|
453
|
+
test_git(repo, "stash", "apply", "--index", stash_oid)
|
|
454
|
+
assert test_git(repo, "show", ":shared.txt") == "staged"
|
|
455
|
+
assert (repo / "shared.txt").read_text(encoding="utf-8") == "unstaged\n"
|
|
456
|
+
assert (repo / "local.txt").read_bytes() == b"local\x00bytes\n"
|
|
457
|
+
assert update_in(repo)[0] == 1
|
|
458
|
+
|
|
459
|
+
_seed, repo = setup_repo(root / "stash-store-failure")
|
|
460
|
+
(repo / "shared.txt").write_text("staged\n", encoding="utf-8")
|
|
461
|
+
test_git(repo, "add", "shared.txt")
|
|
462
|
+
(repo / "shared.txt").write_text("unstaged\n", encoding="utf-8")
|
|
463
|
+
(repo / "local.txt").write_bytes(b"local\x00bytes\n")
|
|
464
|
+
before_status = test_git(repo, "status", "--porcelain=v1")
|
|
465
|
+
stash_lock = repo / ".git" / "refs" / "stash.lock"
|
|
466
|
+
stash_lock.touch()
|
|
467
|
+
try:
|
|
468
|
+
assert update_in(repo)[0] == 1
|
|
469
|
+
finally:
|
|
470
|
+
stash_lock.unlink()
|
|
471
|
+
assert test_git(repo, "status", "--porcelain=v1") == before_status
|
|
472
|
+
assert test_git(repo, "show", ":shared.txt") == "staged"
|
|
473
|
+
assert (repo / "shared.txt").read_text(encoding="utf-8") == "unstaged\n"
|
|
474
|
+
assert (repo / "local.txt").read_bytes() == b"local\x00bytes\n"
|
|
475
|
+
assert not test_git(repo, "stash", "list")
|
|
476
|
+
pending = test_git(repo, "for-each-ref", "--format=%(refname)", PENDING_PREFIX)
|
|
477
|
+
assert len(pending.splitlines()) == 1 and update_in(repo)[0] == 1
|
|
478
|
+
test_git(repo, "update-ref", "-d", pending) # Explicit completion after byte/index verification.
|
|
479
|
+
assert update_in(repo)[0] == 0
|
|
480
|
+
|
|
481
|
+
for phase in ("capture", "restore"):
|
|
482
|
+
seed, repo = setup_repo(root / f"stash-race-{phase}")
|
|
483
|
+
main_sha = commit(seed, "main.txt", "main\n", "test: main")
|
|
484
|
+
test_git(seed, "push", "origin", "main")
|
|
485
|
+
peer = root / f"peer-{phase}"
|
|
486
|
+
test_git(repo, "worktree", "add", "--detach", os.fspath(peer), "HEAD")
|
|
487
|
+
(repo / "shared.txt").write_text("staged\n", encoding="utf-8")
|
|
488
|
+
test_git(repo, "add", "shared.txt")
|
|
489
|
+
(repo / "shared.txt").write_text("unstaged\n", encoding="utf-8")
|
|
490
|
+
(repo / "local.txt").write_bytes(b"local\x00bytes\n")
|
|
491
|
+
(peer / "peer.txt").write_text("peer backup\n", encoding="utf-8")
|
|
492
|
+
real_run = run
|
|
493
|
+
peer_oid = None
|
|
494
|
+
|
|
495
|
+
def push_peer_stash(args, **kwargs):
|
|
496
|
+
nonlocal peer_oid
|
|
497
|
+
if phase == "restore" and args[:2] in (["stash", "apply"], ["stash", "pop"]):
|
|
498
|
+
real_run(["stash", "push", "--include-untracked", "-m", "peer backup"], cwd=peer)
|
|
499
|
+
peer_oid = test_git(peer, "rev-parse", "refs/stash")
|
|
500
|
+
result = real_run(args, **kwargs)
|
|
501
|
+
if phase == "capture" and args[:2] == ["stash", "push"]:
|
|
502
|
+
real_run(["stash", "push", "--include-untracked", "-m", "peer backup"], cwd=peer)
|
|
503
|
+
peer_oid = test_git(peer, "rev-parse", "refs/stash")
|
|
504
|
+
return result
|
|
505
|
+
|
|
506
|
+
with mock.patch(__name__ + ".run", side_effect=push_peer_stash):
|
|
507
|
+
result, output = update_in(repo)
|
|
508
|
+
assert result == 0, output
|
|
509
|
+
own_oid = re.search(r"stash=([0-9a-f]+):retained", output).group(1)
|
|
510
|
+
assert own_oid != peer_oid and f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
511
|
+
assert test_git(repo, "show", ":shared.txt") == "staged"
|
|
512
|
+
assert (repo / "shared.txt").read_text(encoding="utf-8") == "unstaged\n"
|
|
513
|
+
assert (repo / "local.txt").read_bytes() == b"local\x00bytes\n"
|
|
514
|
+
assert test_git(peer, "rev-parse", "refs/stash") == peer_oid
|
|
515
|
+
assert test_git(peer, "show", f"{peer_oid}^3:peer.txt") == "peer backup"
|
|
516
|
+
assert len(test_git(repo, "stash", "list").splitlines()) == 2
|
|
517
|
+
assert update_in(repo)[0] == 1
|
|
518
|
+
|
|
519
|
+
_seed, repo = setup_repo(root / "legacy-backup")
|
|
520
|
+
owner = root / "legacy-owner"
|
|
521
|
+
test_git(repo, "worktree", "add", "-b", "owner", os.fspath(owner), "HEAD")
|
|
522
|
+
(owner / "legacy.txt").write_text("legacy\n", encoding="utf-8")
|
|
523
|
+
test_git(owner, "stash", "push", "--include-untracked", "-m", "update-from-main")
|
|
524
|
+
legacy_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
525
|
+
assert update_in(repo)[0] == 1 # An unowned legacy backup blocks every worktree.
|
|
526
|
+
assert update_in(owner)[0] == 1
|
|
527
|
+
# Owner-confirmed adoption keeps the entry, blocks its owner, and unblocks other worktrees.
|
|
528
|
+
test_git(owner, "update-ref", BACKUP_REF, legacy_oid, "")
|
|
529
|
+
test_git(owner, "update-ref", ADOPTED_PREFIX + legacy_oid, legacy_oid, "")
|
|
530
|
+
assert update_in(repo)[0] == 0
|
|
531
|
+
assert update_in(owner)[0] == 1
|
|
532
|
+
assert test_git(repo, "stash", "list", "--format=%H %gs") == f"{legacy_oid} On owner: update-from-main"
|
|
533
|
+
|
|
534
|
+
seed, repo = setup_repo(root / "conflict")
|
|
535
|
+
commit(repo, "conflict.txt", "feature\n", "test: feature")
|
|
536
|
+
main_sha = commit(seed, "conflict.txt", "main\n", "test: main")
|
|
537
|
+
test_git(seed, "push", "origin", "main")
|
|
538
|
+
(repo / "deferred.txt").write_text("deferred\n", encoding="utf-8")
|
|
539
|
+
result, output = update_in(repo)
|
|
540
|
+
assert result == 2
|
|
541
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
542
|
+
assert "status=merge_conflict" in output
|
|
543
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
544
|
+
assert f"stash={stash_oid}:retained conflicts=1" in output
|
|
545
|
+
assert 'conflict_paths=["conflict.txt"]' in output.splitlines()
|
|
546
|
+
assert not (repo / "deferred.txt").exists()
|
|
547
|
+
assert test_git(repo, "diff", "--name-only", "--diff-filter=U") == "conflict.txt"
|
|
548
|
+
assert test_git(repo, "rev-parse", FETCHED_MAIN_REF) == main_sha
|
|
549
|
+
# Recovery merges the captured source, even after the upstream branch advances.
|
|
550
|
+
commit(seed, "later.txt", "later\n", "test: later main")
|
|
551
|
+
test_git(seed, "push", "origin", "main")
|
|
552
|
+
(repo / "conflict.txt").write_text("resolved\n", encoding="utf-8")
|
|
553
|
+
test_git(repo, "add", "conflict.txt")
|
|
554
|
+
test_git(repo, "-c", "core.editor=true", "merge", "--continue")
|
|
555
|
+
test_git(repo, "stash", "apply", "--index", stash_oid)
|
|
556
|
+
assert (repo / "deferred.txt").read_text(encoding="utf-8") == "deferred\n"
|
|
557
|
+
assert not (repo / "later.txt").exists()
|
|
558
|
+
test_git(repo, "merge-base", "--is-ancestor", main_sha, "HEAD")
|
|
559
|
+
assert update_in(repo)[0] == 1 # A retained backup blocks a second merge run.
|
|
560
|
+
assert test_git(repo, "rev-parse", "refs/stash") == stash_oid
|
|
561
|
+
|
|
562
|
+
seed, repo = setup_repo(root / "ignored")
|
|
563
|
+
main_sha = commit(seed, "ignored.txt", "upstream\n", "test: main")
|
|
564
|
+
test_git(seed, "push", "origin", "main")
|
|
565
|
+
exclude = repo / ".git" / "info" / "exclude"
|
|
566
|
+
exclude.write_text(exclude.read_text(encoding="utf-8") + "\n/ignored.txt\n", encoding="utf-8")
|
|
567
|
+
(repo / "ignored.txt").write_text("local\n", encoding="utf-8")
|
|
568
|
+
(repo / "sub").mkdir()
|
|
569
|
+
result, output = update_in(repo / "sub")
|
|
570
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
571
|
+
assert result == 3
|
|
572
|
+
assert "status=stash_restore_failed" in output
|
|
573
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
574
|
+
assert f"stash={stash_oid}:retained" in output
|
|
575
|
+
assert (repo / "ignored.txt").read_text(encoding="utf-8") == "upstream\n"
|
|
576
|
+
assert test_git(repo, "stash", "show", "--include-untracked", "--name-only", stash_oid) == "ignored.txt"
|
|
577
|
+
|
|
578
|
+
seed, repo = setup_repo(root / "untracked-collision")
|
|
579
|
+
main_sha = commit(seed, "new.txt", "upstream\n", "test: main collision")
|
|
580
|
+
test_git(seed, "push", "origin", "main")
|
|
581
|
+
(repo / "new.txt").write_text("local\n", encoding="utf-8")
|
|
582
|
+
result, output = update_in(repo)
|
|
583
|
+
assert result == 3 and "status=stash_restore_failed" in output
|
|
584
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
585
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
586
|
+
assert f"stash={stash_oid}:retained" in output
|
|
587
|
+
assert test_git(repo, "show", f"{stash_oid}^3:new.txt") == "local"
|
|
588
|
+
assert (repo / "new.txt").read_text(encoding="utf-8") == "upstream\n"
|
|
589
|
+
assert update_in(repo)[0] == 1
|
|
590
|
+
assert test_git(repo, "rev-parse", "refs/stash") == stash_oid
|
|
591
|
+
assert test_git(repo, "rev-parse", BACKUP_REF) == stash_oid
|
|
592
|
+
# The user restores the chosen version, then explicitly completes recovery.
|
|
593
|
+
(repo / "new.txt").write_text(test_git(repo, "show", f"{stash_oid}^3:new.txt") + "\n", encoding="utf-8")
|
|
594
|
+
test_git(repo, "update-ref", "-d", BACKUP_REF, stash_oid)
|
|
595
|
+
result, output = update_in(repo)
|
|
596
|
+
assert result == 0 and (repo / "new.txt").read_text(encoding="utf-8") == "local\n", output
|
|
597
|
+
|
|
598
|
+
seed, repo = setup_repo(root / "fetch-failure")
|
|
599
|
+
(repo / "shared.txt").write_text("staged\n", encoding="utf-8")
|
|
600
|
+
test_git(repo, "add", "shared.txt")
|
|
601
|
+
(repo / "untracked.txt").write_text("local\n", encoding="utf-8")
|
|
602
|
+
before_status = test_git(repo, "status", "--porcelain=v1")
|
|
603
|
+
before_head = test_git(repo, "rev-parse", "HEAD")
|
|
604
|
+
test_git(repo, "remote", "set-url", "origin", os.fspath(root / "absent.git"))
|
|
605
|
+
assert update_in(repo)[0] == 1
|
|
606
|
+
assert test_git(repo, "status", "--porcelain=v1") == before_status
|
|
607
|
+
assert test_git(repo, "rev-parse", "HEAD") == before_head
|
|
608
|
+
assert not test_git(repo, "stash", "list")
|
|
609
|
+
|
|
610
|
+
seed, repo = setup_repo(root / "captured-fetch")
|
|
611
|
+
main_sha = commit(seed, "captured.txt", "captured\n", "test: captured main")
|
|
612
|
+
test_git(seed, "push", "origin", "main")
|
|
613
|
+
later = commit(seed, "later.txt", "later\n", "test: later main")
|
|
614
|
+
test_git(repo, "fetch", os.fspath(seed), later)
|
|
615
|
+
real_run = run
|
|
616
|
+
|
|
617
|
+
def moving_tracking_ref(args, **kwargs):
|
|
618
|
+
result = real_run(args, **kwargs)
|
|
619
|
+
if args[:2] == ["fetch", "--verbose"]:
|
|
620
|
+
real_run(["update-ref", FETCHED_MAIN_REF, later], cwd=repo)
|
|
621
|
+
return result
|
|
622
|
+
|
|
623
|
+
with mock.patch(__name__ + ".run", side_effect=moving_tracking_ref):
|
|
624
|
+
result, output = update_in(repo)
|
|
625
|
+
assert result == 0 and f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
626
|
+
assert test_git(repo, "rev-parse", "HEAD") == main_sha
|
|
627
|
+
assert test_git(repo, "rev-parse", FETCHED_MAIN_REF) == later
|
|
628
|
+
assert not (repo / "later.txt").exists()
|
|
629
|
+
assert update_in(repo)[0] == 0 # Up-to-date, with no retained backup.
|
|
630
|
+
test_git(repo, "checkout", "main")
|
|
631
|
+
assert update_in(repo)[0] == 1
|
|
632
|
+
test_git(repo, "checkout", "--detach", "feature")
|
|
633
|
+
assert update_in(repo)[0] == 1
|
|
634
|
+
|
|
635
|
+
seed, repo = setup_repo(root / "ignored-newline")
|
|
636
|
+
name = "ignored\nname.txt"
|
|
637
|
+
main_sha = commit(seed, name, "upstream\n", "test: newline collision")
|
|
638
|
+
test_git(seed, "push", "origin", "main")
|
|
639
|
+
(repo / ".git" / "info" / "exclude").write_text("*.txt\n", encoding="utf-8")
|
|
640
|
+
(repo / name).write_text("local\n", encoding="utf-8")
|
|
641
|
+
result, output = update_in(repo)
|
|
642
|
+
assert result == 3 and "status=stash_restore_failed" in output
|
|
643
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
644
|
+
assert test_git(repo, "show", f"{stash_oid}^3:{name}") == "local"
|
|
645
|
+
assert (repo / name).read_text(encoding="utf-8") == "upstream\n"
|
|
646
|
+
|
|
647
|
+
seed, repo = setup_repo(root / "stash-conflict")
|
|
648
|
+
name = "stash conflict.txt"
|
|
649
|
+
commit(seed, name, "base\n", "test: add stash conflict fixture")
|
|
650
|
+
test_git(seed, "push", "origin", "main")
|
|
651
|
+
test_git(repo, "fetch", "origin", "main")
|
|
652
|
+
test_git(repo, "merge", "--ff-only", "origin/main")
|
|
653
|
+
main_sha = commit(seed, name, "main\n", "test: main")
|
|
654
|
+
test_git(seed, "push", "origin", "main")
|
|
655
|
+
(repo / name).write_text("local\n", encoding="utf-8")
|
|
656
|
+
result, output = update_in(repo)
|
|
657
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
658
|
+
assert result == 3
|
|
659
|
+
assert "status=stash_conflict" in output
|
|
660
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
661
|
+
assert f"stash={stash_oid}:retained conflicts=1" in output
|
|
662
|
+
assert f'conflict_paths=["{name}"]' in output.splitlines()
|
|
663
|
+
assert run(["diff", "--name-only", "--diff-filter=U", "-z"], cwd=repo).stdout == f"{name}\0"
|
|
664
|
+
|
|
665
|
+
child = root / "submodule-child"
|
|
666
|
+
run(["init", os.fspath(child)])
|
|
667
|
+
test_git(child, "config", "user.email", "agent@example.invalid")
|
|
668
|
+
test_git(child, "config", "user.name", "Agent")
|
|
669
|
+
commit(child, "child.txt", "base\n", "test: child")
|
|
670
|
+
_seed, repo = setup_repo(root / "submodule")
|
|
671
|
+
test_git(repo, "-c", "protocol.file.allow=always", "submodule", "add", os.fspath(child), "sub")
|
|
672
|
+
test_git(repo, "commit", "-am", "test: add submodule")
|
|
673
|
+
(repo / "ordinary.txt").write_text("ordinary\n", encoding="utf-8")
|
|
674
|
+
(repo / "sub" / "child.txt").write_text("dirty\n", encoding="utf-8")
|
|
675
|
+
result, _output = update_in(repo)
|
|
676
|
+
assert result == 1
|
|
677
|
+
assert (repo / "ordinary.txt").read_text(encoding="utf-8") == "ordinary\n"
|
|
678
|
+
assert (repo / "sub" / "child.txt").read_text(encoding="utf-8") == "dirty\n"
|
|
679
|
+
assert test_git(repo, "stash", "list") # Error-path restoration also retains its backup.
|
|
680
|
+
assert test_git(repo, "rev-parse", BACKUP_REF) == test_git(repo, "rev-parse", "refs/stash")
|
|
681
|
+
|
|
682
|
+
seed, repo = setup_repo(root / "submodule-update")
|
|
683
|
+
child = root / "submodule-update-child"
|
|
684
|
+
run(["init", os.fspath(child)])
|
|
685
|
+
test_git(child, "config", "user.email", "agent@example.invalid")
|
|
686
|
+
test_git(child, "config", "user.name", "Agent")
|
|
687
|
+
child_base = commit(child, "child.txt", "base\n", "test: child")
|
|
688
|
+
test_git(seed, "-c", "protocol.file.allow=always", "submodule", "add", os.fspath(child), "sub")
|
|
689
|
+
test_git(seed, "commit", "-am", "test: add submodule")
|
|
690
|
+
test_git(seed, "push", "origin", "main")
|
|
691
|
+
test_git(repo, "fetch", "origin", "main")
|
|
692
|
+
test_git(repo, "merge", "--ff-only", "origin/main")
|
|
693
|
+
run(["-c", "protocol.file.allow=always", "submodule", "update", "--init", "--checkout"], cwd=repo)
|
|
694
|
+
assert test_git(repo / "sub", "rev-parse", "HEAD") == child_base
|
|
695
|
+
commit(repo, "feature.txt", "feature\n", "test: feature")
|
|
696
|
+
child_sha = commit(child, "child.txt", "main\n", "test: child update")
|
|
697
|
+
test_git(seed / "sub", "fetch", "origin")
|
|
698
|
+
test_git(seed / "sub", "checkout", child_sha)
|
|
699
|
+
test_git(seed, "add", "sub")
|
|
700
|
+
test_git(seed, "commit", "-m", "test: update submodule")
|
|
701
|
+
main_sha = test_git(seed, "rev-parse", "HEAD")
|
|
702
|
+
test_git(seed, "push", "origin", "main")
|
|
703
|
+
(repo / "deferred.txt").write_text("deferred\n", encoding="utf-8")
|
|
704
|
+
result, output = update_in(repo)
|
|
705
|
+
assert result == 4
|
|
706
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
707
|
+
assert "status=submodule_update_required" in output
|
|
708
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
709
|
+
assert f"stash={stash_oid}:retained" in output
|
|
710
|
+
assert not (repo / "deferred.txt").exists()
|
|
711
|
+
assert test_git(repo / "sub", "rev-parse", "HEAD") == child_base
|
|
712
|
+
assert "M sub" in test_git(repo, "status", "--porcelain=v1")
|
|
713
|
+
test_git(repo, "-c", "protocol.file.allow=always", "submodule", "update", "--checkout", "--recursive")
|
|
714
|
+
assert test_git(repo / "sub", "rev-parse", "HEAD") == child_sha
|
|
715
|
+
test_git(repo, "stash", "apply", "--index", stash_oid)
|
|
716
|
+
assert (repo / "deferred.txt").read_text(encoding="utf-8") == "deferred\n"
|
|
717
|
+
|
|
718
|
+
seed, repo = setup_repo(root / "merge-hook")
|
|
719
|
+
commit(repo, "feature.txt", "feature\n", "test: feature")
|
|
720
|
+
main_sha = commit(seed, "main.txt", "main\n", "test: main")
|
|
721
|
+
test_git(seed, "push", "origin", "main")
|
|
722
|
+
(repo / "deferred.txt").write_text("deferred\n", encoding="utf-8")
|
|
723
|
+
hook = repo / ".git" / "hooks" / "pre-merge-commit"
|
|
724
|
+
hook.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
725
|
+
hook.chmod(0o755)
|
|
726
|
+
result, output = update_in(repo)
|
|
727
|
+
assert result == 2
|
|
728
|
+
stash_oid = test_git(repo, "rev-parse", "refs/stash")
|
|
729
|
+
assert "status=merge_pending" in output
|
|
730
|
+
assert f"main={FETCHED_MAIN_REF}:{main_sha}" in output
|
|
731
|
+
assert f"stash={stash_oid}:retained" in output
|
|
732
|
+
assert (repo / ".git" / "MERGE_HEAD").exists()
|
|
733
|
+
assert not (repo / "deferred.txt").exists()
|
|
734
|
+
hook.unlink()
|
|
735
|
+
run(["-c", "core.editor=true", "merge", "--continue"], cwd=repo)
|
|
736
|
+
test_git(repo, "stash", "apply", "--index", stash_oid)
|
|
737
|
+
assert (repo / "deferred.txt").read_text(encoding="utf-8") == "deferred\n"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def main(argv: list[str]) -> int:
|
|
741
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
742
|
+
parser.add_argument("--self-test", action="store_true", help="Run internal checks and exit")
|
|
743
|
+
args = parser.parse_args(argv)
|
|
744
|
+
if args.self_test:
|
|
745
|
+
self_test()
|
|
746
|
+
print("update-from-main self-test ok: update and recovery paths")
|
|
747
|
+
return 0
|
|
748
|
+
return update_from_main()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
if __name__ == "__main__":
|
|
752
|
+
raise SystemExit(main(sys.argv[1:]))
|