@henryqw/pi-pr 9.1.0 → 9.2.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/NOTICE.md CHANGED
@@ -15,8 +15,8 @@ Each source path below is relative to that repository. Each package path is rela
15
15
  | --- | --- | --- |
16
16
  | `git-commit/SKILL.md` | `skills/git-commit/SKILL.md` | Modified for pi-pr: use the shared native scoped commit tool, content/index checks, and uncertain-outcome rules. |
17
17
  | `git-pr/SKILL.md` | `skills/git-pr/SKILL.md` | Modified for pi-pr: replace shell publication with bounded native dispatch, explicit base intent, upstream consent, and exact PR reuse. |
18
- | `update-from-main/SKILL.md` | `skills/update-from-main/SKILL.md` | Modified for pi-pr: packaged helper guidance, bounded check output, exact backup identity, and retained-backup recovery after success or failure. |
19
- | `update-from-main/scripts/update_from_main.py` | `skills/update-from-main/scripts/update_from_main.py` | Modified for pi-pr: add collision/race regressions, identify the backup by a unique reflog message, and apply its exact OID without popping or dropping it. |
18
+ | `update-from-main/SKILL.md` | `skills/update-from-main/SKILL.md` | Modified for pi-pr: packaged helper guidance, bounded check output, exact backup identity, worktree ownership, and retained-backup recovery after success or failure. |
19
+ | `update-from-main/scripts/update_from_main.py` | `skills/update-from-main/scripts/update_from_main.py` | Modified for pi-pr: add collision/race regressions, identify the backup by a unique reflog message, record its owner in per-worktree refs, and apply its exact OID without popping or dropping it. |
20
20
  | `update-from-main/scripts/validate.py` | `skills/update-from-main/scripts/validate.py` | Copied validator; added the source, licence, and modification notice header. |
21
21
 
22
22
  The source snapshot has no separate `NOTICE` file and no file-specific copyright notice in these five files. The full original Apache licence, including its appendix, is preserved unchanged. Attribution to the source author and repository is retained here. The unrelated source `agents/openai.yaml` files were not imported.
package/README.md CHANGED
@@ -8,7 +8,7 @@ See the current branch pull request in the Pi footer. Run `/pr` for the full PR
8
8
  pi install npm:@henryqw/pi-pr
9
9
  ```
10
10
 
11
- Requires Pi 1.0.0 or newer, an authenticated GitHub CLI session (`gh auth login`) and a GitHub.com or GitHub Enterprise checkout. Verify authentication with `gh auth status`. CI repair requires a GitHub CLI whose `gh api --help` lists `--allow-escape-sequences`; upgrade `gh` if that flag is unavailable.
11
+ Requires Pi 1.1.0 or newer in the 1.x series, an authenticated GitHub CLI session (`gh auth login`) and a GitHub.com or GitHub Enterprise checkout. Verify authentication with `gh auth status`. CI repair requires a GitHub CLI whose `gh api --help` lists `--allow-escape-sequences`; upgrade `gh` if that flag is unavailable.
12
12
 
13
13
  ## Works with
14
14
 
@@ -68,7 +68,9 @@ All commit callers use the same literal-path staging and commit parent/tree chec
68
68
 
69
69
  Ask to update the current branch from main. `update-from-main` runs its packaged Python helper once. It requires Python 3.9 or newer and Git with `fetch --porcelain`. Unlike the `/pr` base-update route, this helper **merges**, not rebases. It fetches before local mutation and uses the exact OID from fetch output. It backs up tracked and untracked changes; when ignored paths collide with the source tree, the backup also includes ignored files. It never pushes.
70
70
 
71
- Each backup has a unique Git reflog message, so another linked worktree's stash push cannot change its identity. Restoration always applies the captured OID with `--index`. The helper never pops or drops a stash. Backups stay retained after success as well as conflict, hook, or submodule failures. Report the backup OID and verify restored bytes and staging before explicitly removing it. Do not rerun while a backup remains. Restore from the emitted stash OID, not from a later `origin/main`, and keep the backup until the user verifies restoration and explicitly removes it. See the packaged `update-from-main` skill for recovery.
71
+ Each backup has a unique Git reflog message, so another linked worktree's stash push cannot change its identity. Before it stashes, the helper records ownership in the per-worktree ref `refs/worktree/update-from-main/pending/<token>`, then replaces it with `refs/worktree/update-from-main/backup` at the captured OID. Restoration always applies the captured OID with `--index`. The helper never pops or drops a stash. Backups stay retained after success as well as conflict, hook, or submodule failures.
72
+
73
+ A retained backup blocks further runs only in the worktree that owns it, also after a branch switch; other worktrees can update. Report the backup OID, and restore from it rather than from a later `origin/main`. After the user verifies restored bytes and staging and explicitly asks, complete recovery with `git update-ref -d refs/worktree/update-from-main/backup <oid>`. This keeps the stash entry. An interrupted run leaves the `pending` ref and blocks its worktree until recovery. Backups from earlier helper versions have no recorded owner and block every worktree until the user drops them or the confirmed owner adopts them; adoption keeps the stash entry. Removing a worktree removes its ownership refs but not its stash entries. See the packaged `update-from-main` skill for the exact recovery and adoption commands.
72
74
 
73
75
  ### Local acceptance checks
74
76
 
@@ -112,7 +112,7 @@ Main can give separate acceptance workers the source `extensions/pi-pr/extension
112
112
 
113
113
  ## Limits
114
114
 
115
- - Automated native tests use the workspace-pinned Pi `1.0.0` dependency. The installed `1.0.2` authority is `/Users/henry/.pi/agent/install/releases/1.0.2/node_modules/@earendil-works/pi-coding-agent`; its documentation and declarations were inspected, and its resource APIs were probed. Live model-driven use of the packaged tools and TUI behavior were not tested.
115
+ - The automated native tests in this report used the workspace-pinned Pi `1.0.0` dependency of that time. The package now requires and pins Pi `1.1.0`; its test suite runs on that version. The installed `1.0.2` authority is `/Users/henry/.pi/agent/install/releases/1.0.2/node_modules/@earendil-works/pi-coding-agent`; its documentation and declarations were inspected, and its resource APIs were probed. Live model-driven use of the packaged tools and TUI behavior were not tested.
116
116
  - Controlled GitHub responses test authorization and command boundaries. They do not prove live GitHub authentication, repository policy, network transport, or GitHub Enterprise behavior.
117
117
  - A user request and upstream consent are agent guidance requirements; a Boolean tool field cannot prove that a person gave consent. Ownership, secret-content detection, check adequacy, and semantic conflict resolution still need agent/user judgment.
118
118
  - The acceptance entry is available for later local runs. Main's exact-candidate acceptance and root-wide checks are recorded above. Package typecheck passed on the revision; no new claim is made for unchanged packages' typechecks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,8 +31,8 @@
31
31
  "pack:check": "npm pack --dry-run"
32
32
  },
33
33
  "peerDependencies": {
34
- "@earendil-works/pi-coding-agent": ">=1.0.0",
35
- "@earendil-works/pi-tui": ">=1.0.0",
34
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
35
+ "@earendil-works/pi-tui": ">=1.1.0",
36
36
  "typebox": "^1.3.15"
37
37
  },
38
38
  "dependencies": {
@@ -42,8 +42,8 @@
42
42
  "proper-lockfile": "^4.1.2"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-coding-agent": "1.0.0",
46
- "@earendil-works/pi-tui": "1.0.0",
45
+ "@earendil-works/pi-coding-agent": "1.1.0",
46
+ "@earendil-works/pi-tui": "1.1.0",
47
47
  "@types/proper-lockfile": "^4.1.4"
48
48
  },
49
49
  "repository": {
@@ -5,9 +5,9 @@ description: Safely sync attached non-`main` worktree branch with fetched `origi
5
5
 
6
6
  # update-from-main
7
7
 
8
- <!-- SPDX-License-Identifier: Apache-2.0. Derived from HenryQW/skills; modified for pi-pr exact backup identity and retained recovery. See ../../NOTICE.md for source identity and changes. -->
8
+ <!-- SPDX-License-Identifier: Apache-2.0. Derived from HenryQW/skills; modified for pi-pr exact backup identity, worktree ownership, and retained recovery. See ../../NOTICE.md for source identity and changes. -->
9
9
 
10
- Run `python3 <skill>/scripts/update_from_main.py` once from the target worktree. Use this file's directory for `<skill>`. Requires Python 3.9 or newer and Git with `fetch --porcelain`. This is a standalone **merge**, not the `/pr` rebase route. The helper fetches before worktree mutation, backs up tracked/untracked state plus ignored paths when they collide, and merges the exact `main` OID captured from fetch output. It does not push or use GitHub. A retained update-from-main stash blocks a second run. The instructions below are the single recovery guide.
10
+ Run `python3 <skill>/scripts/update_from_main.py` once from the target worktree. Use this file's directory for `<skill>`. Requires Python 3.9 or newer and Git with `fetch --porcelain`. This is a standalone **merge**, not the `/pr` rebase route. The helper fetches before worktree mutation, backs up tracked/untracked state plus ignored paths when they collide, and merges the exact `main` OID captured from fetch output. It does not push or use GitHub. Before it stashes, the helper records ownership in this worktree's per-worktree ref. A retained backup blocks another run in its own worktree, also after a branch switch, but not in other worktrees. The instructions below are the single recovery guide.
11
11
 
12
12
  ## Fast path
13
13
 
@@ -18,7 +18,7 @@ git status --short --branch
18
18
  git diff --check <emitted-before-sha> HEAD
19
19
  ```
20
20
 
21
- If `stash` is non-`none`, restoration used that exact OID and kept the backup. Report the OID even on success. A later run stays blocked until the user verifies staged, unstaged, untracked, and applicable ignored bytes, then explicitly removes this backup. Never pop or drop a mutable stash stack entry automatically.
21
+ If `stash` is non-`none`, restoration used that exact OID and kept the backup. Report the OID even on success. A later run in this worktree stays blocked until the user verifies staged, unstaged, untracked, and applicable ignored bytes, then explicitly asks to [complete recovery](#complete-recovery). Never pop or drop a mutable stash stack entry automatically.
22
22
 
23
23
  Stop. No tests, install, history scan, push, extra commit, or helper rerun.
24
24
 
@@ -32,7 +32,47 @@ Emitted `main` SHA is authoritative for run. Do not rerun because `origin/main`
32
32
  - `status=stash_conflict`: resolve and stage every emitted conflict path; retain and report stash OID.
33
33
  - `status=stash_restore_failed`: retain and report stash OID. Inspect once with `git stash show --name-status --include-untracked <oid>`, restore clear changes, and ask user only when one upstream-path collision version must win. Never drop backup first.
34
34
 
35
- After manual recovery, keep the backup and report its OID. A later update stays blocked until the user verifies restoration and explicitly removes that backup. Do not drop it automatically. If execution stops without a complete result, inspect HEAD, Git operation state, and the stash before further action; do not rerun an uncertain mutation.
35
+ After manual recovery, keep the backup and report its OID. A later update in this worktree stays blocked until the user verifies restoration and explicitly asks to complete recovery. Do not drop it automatically. If execution stops without a complete result, inspect HEAD, Git operation state, the stash, and `git for-each-ref refs/worktree/update-from-main/` before further action; do not rerun an uncertain mutation.
36
+
37
+ ### Complete recovery
38
+
39
+ Only after the user verifies the restored bytes and explicitly asks, run in the owning worktree:
40
+
41
+ ```bash
42
+ git update-ref -d refs/worktree/update-from-main/backup <oid>
43
+ ```
44
+
45
+ This unblocks the worktree and keeps the stash entry. Drop that entry only on a separate explicit request, with no concurrent stash changes in any linked worktree. Re-find its `stash@{n}` by OID with `git stash list --format='%gd %H'` immediately before `git stash drop`; a concurrent push can change the selector and cause removal of another backup.
46
+
47
+ ### Interrupted run
48
+
49
+ The helper error names `refs/worktree/update-from-main/pending/<token>` when a run stopped before it recorded its stash OID. Find this run's entry:
50
+
51
+ ```bash
52
+ git stash list --format='%H %gs' | grep -F 'update-from-main owned <token>'
53
+ ```
54
+
55
+ - One entry: record it, remove the pending ref, then restore its exact OID. If `apply` fails, continue as `stash_restore_failed`.
56
+
57
+ ```bash
58
+ git update-ref refs/worktree/update-from-main/backup <oid> ""
59
+ git update-ref -d refs/worktree/update-from-main/pending/<token>
60
+ git stash apply --index <oid>
61
+ ```
62
+
63
+ - No entry: stop and retain the pending ref. Absence does not prove that Git left the worktree unchanged; another session can remove a shared stash entry. Inspect the stash reflog and local state. Only after the user verifies all expected staged, unstaged, untracked, and applicable ignored bytes and explicitly asks to complete recovery, run `git update-ref -d refs/worktree/update-from-main/pending/<token>`. If the expected state cannot be verified, keep the ref and ask the user.
64
+ - More entries, or a `backup` ref is also present: stop and ask the user.
65
+
66
+ ### Legacy backup
67
+
68
+ A stash entry with subject `update-from-main` or `update-from-main <uuid>` came from a helper without worktree ownership. It blocks every worktree until its owner adopts it or the user explicitly drops it. Ask the user to confirm the owner: the worktree that created it (the stash subject names its branch), or, if that worktree is gone, the worktree where they will verify recovery. In the owner worktree, run:
69
+
70
+ ```bash
71
+ git update-ref refs/worktree/update-from-main/backup <oid> ""
72
+ git update-ref refs/update-from-main/adopted/<oid> <oid> ""
73
+ ```
74
+
75
+ The first command blocks the owner; the second unblocks other worktrees. Both keep the stash entry. Then verify or restore it with `git stash apply --index <oid>` as for `stash_restore_failed`, and complete recovery. Keep the shared `adopted` ref while the stash entry exists; delete it with `git update-ref -d refs/update-from-main/adopted/<oid> <oid>` only together with an explicitly requested drop of that entry.
36
76
 
37
77
  Start conflict triage with emitted JSON conflict path list, then inspect marker line numbers one separately quoted path at a time:
38
78
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  # SPDX-License-Identifier: Apache-2.0
3
- # Derived from HenryQW/skills; modified for exact backup identity, retention, and regressions.
3
+ # Derived from HenryQW/skills; modified for exact backup identity, retention, worktree ownership, and regressions.
4
4
  # See ../../../NOTICE.md for the source snapshot and modification notices.
5
5
  """Update current non-main worktree branch from fetched origin/main."""
6
6
 
@@ -23,6 +23,14 @@ from unittest import mock
23
23
  MAIN_SOURCE_REF = "refs/heads/main"
24
24
  FETCHED_MAIN_REF = "refs/remotes/origin/main"
25
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})?$")
26
34
  IN_PROGRESS_PATHS = (
27
35
  "MERGE_HEAD",
28
36
  "CHERRY_PICK_HEAD",
@@ -37,10 +45,13 @@ class UpdateError(RuntimeError):
37
45
  pass
38
46
 
39
47
 
40
- def run(args: list[str], *, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
48
+ def run(
49
+ args: list[str], *, cwd: Path | None = None, check: bool = True, input: str | None = None
50
+ ) -> subprocess.CompletedProcess[str]:
41
51
  result = subprocess.run(
42
52
  ["git", *args],
43
53
  cwd=cwd,
54
+ input=input,
44
55
  text=True,
45
56
  stdout=subprocess.PIPE,
46
57
  stderr=subprocess.PIPE,
@@ -107,9 +118,17 @@ def require_ready_worktree() -> str:
107
118
  if unmerged_paths():
108
119
  raise UpdateError("worktree has unresolved conflicts")
109
120
  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")
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")
113
132
  return branch
114
133
 
115
134
 
@@ -117,14 +136,23 @@ def stash_dirty_worktree(source_sha: str) -> str | None:
117
136
  ignored = ignored_source_paths(source_sha)
118
137
  if not status_lines() and not ignored:
119
138
  return None
120
- message = f"update-from-main {uuid.uuid4()}"
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", ""])
121
144
  mode = "--all" if ignored else "--include-untracked"
122
- run(["stash", "push", mode, "--message", message])
145
+ pushed = run(["stash", "push", mode, "--message", message], check=False)
123
146
  # Linked worktrees share the stash stack. Find this run's entry, not its mutable tip.
124
147
  entries = git(["stash", "list", "--format=%H%x00%gs"]).splitlines()
125
148
  matches = [line.split("\0", 1)[0] for line in entries if line.endswith(f": {message}")]
149
+ detail = (pushed.stderr or pushed.stdout).strip()
126
150
  if len(matches) != 1:
127
- raise UpdateError(f"could not identify backup with message {message}; inspect the stash reflog before retrying")
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]}")
128
156
  return matches[0]
129
157
 
130
158
 
@@ -379,8 +407,76 @@ def self_test() -> None:
379
407
  assert (repo / "main.txt").read_text(encoding="utf-8") == "main\n"
380
408
  assert "A staged.txt" in test_git(repo, "status", "--porcelain=v1", "--untracked-files=all")
381
409
  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
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
384
480
 
385
481
  for phase in ("capture", "restore"):
386
482
  seed, repo = setup_repo(root / f"stash-race-{phase}")
@@ -421,11 +517,19 @@ def self_test() -> None:
421
517
  assert update_in(repo)[0] == 1
422
518
 
423
519
  _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")
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")
426
524
  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
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"
429
533
 
430
534
  seed, repo = setup_repo(root / "conflict")
431
535
  commit(repo, "conflict.txt", "feature\n", "test: feature")
@@ -484,6 +588,12 @@ def self_test() -> None:
484
588
  assert (repo / "new.txt").read_text(encoding="utf-8") == "upstream\n"
485
589
  assert update_in(repo)[0] == 1
486
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
487
597
 
488
598
  seed, repo = setup_repo(root / "fetch-failure")
489
599
  (repo / "shared.txt").write_text("staged\n", encoding="utf-8")
@@ -567,6 +677,7 @@ def self_test() -> None:
567
677
  assert (repo / "ordinary.txt").read_text(encoding="utf-8") == "ordinary\n"
568
678
  assert (repo / "sub" / "child.txt").read_text(encoding="utf-8") == "dirty\n"
569
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")
570
681
 
571
682
  seed, repo = setup_repo(root / "submodule-update")
572
683
  child = root / "submodule-update-child"