@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.
@@ -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:]))