@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.
@@ -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
+ )