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