devlyn-cli 4.3.31 → 4.3.33

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.
Files changed (34) hide show
  1. package/README.md +4 -2
  2. package/bin/devlyn.js +109 -22
  3. package/bin/harness-activation.js +4 -1
  4. package/bin/harness-status.js +15 -8
  5. package/bin/skill-history.json +10 -0
  6. package/config/skills/_shared/bring-in.py +10 -0
  7. package/config/skills/_shared/task-complete.py +787 -87
  8. package/config/skills/_shared/task-completion.md +6 -6
  9. package/config/skills/_shared/warm-build-dirs.py +13 -0
  10. package/config/skills/devlyn-ideate/SKILL.md +3 -2
  11. package/config/skills/devlyn-ideate/references/loop.md +2 -2
  12. package/config/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  13. package/config/skills/devlyn-ideate/scripts/queue.py +685 -10
  14. package/native/claude/.claude-plugin/plugin.json +1 -1
  15. package/native/claude/skills/_shared/bring-in.py +10 -0
  16. package/native/claude/skills/_shared/task-complete.py +787 -87
  17. package/native/claude/skills/_shared/task-completion.md +6 -6
  18. package/native/claude/skills/_shared/warm-build-dirs.py +13 -0
  19. package/native/claude/skills/devlyn-ideate/SKILL.md +3 -2
  20. package/native/claude/skills/devlyn-ideate/references/loop.md +2 -2
  21. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  22. package/native/claude/skills/devlyn-ideate/scripts/queue.py +685 -10
  23. package/native/codex/.agents/plugins/marketplace.json +1 -1
  24. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  25. package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +10 -0
  26. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +787 -87
  27. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +6 -6
  28. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +13 -0
  29. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +3 -2
  30. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -2
  31. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  32. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +685 -10
  33. package/package.json +1 -1
  34. package/release.json +35 -35
@@ -695,6 +695,12 @@ def retained_modules(receipt):
695
695
  + str(Path(gitdir) / 'modules') + '; retain')
696
696
 
697
697
 
698
+ def refuse_submodules(receipt, work, gitlinks):
699
+ retained_modules(receipt)
700
+ require(not any(path_present(work / relative / '.git') for relative in gitlinks),
701
+ 'working tree contains populated submodules; preserve their source and explicitly resolve them; retain')
702
+
703
+
698
704
  def remove_managed_registration(receipt):
699
705
  retained_modules(receipt)
700
706
  require(not path_present(receipt['worktree']), 'managed tree reappeared; retain registration')
@@ -765,10 +771,7 @@ def removal_eligibility(receipt, path, *, abandonment, partial):
765
771
  require(stage == '0' and known.get(relative) == (mode, oid), 'partial checkout index changed; retain: ' + relative)
766
772
  indexed[relative] = (mode, oid)
767
773
  require(abandonment or indexed == known, 'removal index changed; retain')
768
- retained_modules(receipt)
769
- require(not any(mode == '160000' and path_present(work / relative / '.git')
770
- for relative, (mode, oid) in known.items()),
771
- 'working tree contains populated submodules; preserve their source and explicitly resolve them; retain')
774
+ refuse_submodules(receipt, work, [relative for relative, (mode, oid) in known.items() if mode == '160000'])
772
775
  # Git's own comparison applies eol/clean filters and gitlink semantics. A
773
776
  # private baseline index also handles an interrupted checkout's missing entries.
774
777
  with tempfile.TemporaryDirectory(prefix='.removal-index-', dir=path.parent) as temporary:
@@ -1241,7 +1244,11 @@ def removable(receipt, work):
1241
1244
  # native removal would delete it with its uncommitted work.
1242
1245
  nested = [p for p in git(work, "ls-files", "--others", "--ignored", "--exclude-standard", "-z").split("\0") if p.endswith("/")]
1243
1246
  require(not nested, "nested repository or worktree inside the task tree: " + ", ".join(nested) + "; retain")
1244
- if not storage_call('managed_receipt', receipt):
1247
+ if storage_call('managed_receipt', receipt):
1248
+ return
1249
+ if sys.platform == "win32":
1250
+ outside(work) # The move that starts the removal is the writer check there (move_aside).
1251
+ else:
1245
1252
  stopped_writers(work)
1246
1253
 
1247
1254
 
@@ -1369,6 +1376,10 @@ def clean_scratch(receipt, path, writers_stopped):
1369
1376
  require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
1370
1377
  identity = workspace_identity(scratch, path.parent)
1371
1378
  require(same_workspace_identity(identity, receipt["scratch_identity"]), "scratch directory was replaced; retain")
1379
+ if sys.platform == "win32" and not any(scratch.iterdir()): # Nothing to delete, so nothing the refusals below guard.
1380
+ receipt["scratch_cleanup"] = {"status": "CLEAN", "logical_bytes_removed": 0}
1381
+ atomic_json(path, receipt)
1382
+ return receipt["scratch_cleanup"]
1372
1383
  require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
1373
1384
  require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
1374
1385
  stopped_writers(scratch)
@@ -1417,7 +1428,110 @@ def verify_custody(receipt, path):
1417
1428
  require(read_json(path.parent / 'manifest.json') == receipt['files'], 'custody manifest changed')
1418
1429
 
1419
1430
 
1420
- def cleanup_workspace(receipt, path, *, warnings):
1431
+ def preserve_records(receipt, path, work):
1432
+ records = records_manifest(work)
1433
+ if records:
1434
+ destination = path.parent / "records"
1435
+ if os.path.lexists(destination) and records_manifest(destination) != records:
1436
+ digest = hashlib.sha256(json.dumps(records, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
1437
+ destination = path.parent / ("records." + digest)
1438
+ if not os.path.lexists(destination):
1439
+ with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
1440
+ shutil.copytree(safe_path(work, ".devlyn"), Path(temporary) / ".devlyn", symlinks=True)
1441
+ require(records_manifest(Path(temporary)) == records, ".devlyn changed while it was copied; stop its writer and resume")
1442
+ Path(temporary).rename(destination)
1443
+ require(records_manifest(destination) == records, f"records copy mismatch; repair and resume: {destination}")
1444
+ if storage_call("managed_receipt", receipt):
1445
+ receipt["storage"]["records_custody"] = {"path": str(destination), "files": records}
1446
+ atomic_json(path, receipt)
1447
+ return records
1448
+
1449
+
1450
+ def aside(work):
1451
+ return work.with_name(work.name + ".devlyn-removing")
1452
+
1453
+
1454
+ @contextlib.contextmanager
1455
+ def long_paths():
1456
+ # The move lengthens every path in the tree; Git for Windows reads and deletes past MAX_PATH only with long paths.
1457
+ # Every Git child started in the span inherits it, after the entries the environment already has (Git 2.31).
1458
+ count = int(os.environ.get("GIT_CONFIG_COUNT") or 0)
1459
+ names = ("GIT_CONFIG_COUNT", f"GIT_CONFIG_KEY_{count}", f"GIT_CONFIG_VALUE_{count}")
1460
+ saved = {name: os.environ.get(name) for name in names}
1461
+ os.environ.update(zip(names, (str(count + 1), "core.longpaths", "true")))
1462
+ try:
1463
+ yield
1464
+ finally:
1465
+ for name, value in saved.items():
1466
+ if value is None:
1467
+ os.environ.pop(name, None)
1468
+ else:
1469
+ os.environ[name] = value
1470
+
1471
+
1472
+ def move_aside(work):
1473
+ # Windows refuses to rename a folder while a process holds a handle in it or below it. The Windows error stays in
1474
+ # the message: a refusal can also be an access denied for another reason.
1475
+ try:
1476
+ os.rename(work, aside(work))
1477
+ except PermissionError as exc:
1478
+ raise WriterActive(f"a program still uses {work} ({exc}); close it, then resume") from exc
1479
+ except OSError as exc:
1480
+ raise WritersUnobservable(f"cannot move {work} aside to remove it ({exc}); retain") from exc
1481
+
1482
+
1483
+ def remove_by_moving(receipt, work):
1484
+ """The Windows removal of a merged or on-base tree: its move is the writer check and is never undone."""
1485
+ moved = aside(work)
1486
+ # Git's unforced removal would refuse these after the move, so they are refused while the tree is in place.
1487
+ refuse_submodules(receipt, work, [item.split("\t", 1)[1] for item in
1488
+ gref(receipt, "ls-tree", "-r", "-z", receipt["publish_sha"]).split("\0")
1489
+ if item.startswith("160000 ")])
1490
+ require(not path_present(moved) and all(not moved.is_relative_to(p) and not p.is_relative_to(moved)
1491
+ for p in map(Path, registrations(receipt))),
1492
+ f"{moved} exists or overlaps a registered worktree; leave it, keep {work}, inspect both, then resume")
1493
+ version = re.match(r"git version (\d+)\.(\d+)", command(["git", "--version"]))
1494
+ if not version or (int(version[1]), int(version[2])) < (2, 31):
1495
+ raise WritersUnobservable("removing a task checkout on Windows needs Git 2.31 or later; update Git, then resume")
1496
+ move_aside(work)
1497
+ with long_paths():
1498
+ gref(receipt, "worktree", "repair", str(moved))
1499
+ gref(receipt, "worktree", "remove", str(moved))
1500
+
1501
+
1502
+ @long_paths()
1503
+ def remove_moved(receipt, path, work, anchor):
1504
+ """Finish a Windows removal interrupted after its move, forward only, rechecking the moved tree."""
1505
+ require(receipt.get("writers_released"), "wait actual children, stop/yield known writers, then resume with --writers-stopped")
1506
+ moved = aside(work)
1507
+ foreign = f"{moved} cannot be verified as the task checkout moved from {work}; leave both, inspect them, then resume"
1508
+ pointer = moved / ".git"
1509
+ require(path_present(pointer) and stat.S_ISREG(pointer.lstat().st_mode), foreign)
1510
+ kind, _, value = pointer.read_text(encoding="utf-8").partition(":")
1511
+ gitdir = Path(os.path.normpath(moved / value.strip()))
1512
+ require(kind == "gitdir" and gitdir == Path(receipt["worktree_gitdir"]), foreign)
1513
+ # A prune, or a Git removal that failed part way, deletes the registration that Git needs to remove the tree. Only
1514
+ # the folder's own inode, which a rename keeps, then tells the checkout from another folder naming that Gitdir.
1515
+ if not path_present(gitdir):
1516
+ require(moved.stat().st_ino == receipt["worktree_identity"][0][1], foreign)
1517
+ raise CompletionError(f"{moved} is the task checkout moved from {work}, but its Git registration is gone; "
1518
+ "inspect it and remove it by hand, then resume")
1519
+ require(gitdir.is_dir() and same_workspace_identity(workspace_identity(moved, gitdir), receipt["worktree_identity"]), foreign)
1520
+ # Until the repair, Git may list the tree at its original path only, never another registration at the moved one.
1521
+ row = registrations(receipt).get(str(moved.resolve()))
1522
+ require(row is None or row.get("branch") == "refs/heads/" + receipt["branch"], foreign)
1523
+ verify_files(moved, receipt["files"])
1524
+ records = preserve_records(receipt, path, moved)
1525
+ outside(moved)
1526
+ require(anchor is None or not (os.path.samefile(moved, anchor) if os.path.exists(anchor) else moved == anchor),
1527
+ "cannot clean the current allocation's anchor checkout: " + str(anchor))
1528
+ gref(receipt, "worktree", "repair", str(moved))
1529
+ removable(dict(receipt, worktree=str(moved)), moved)
1530
+ require(records_manifest(moved) == records, ".devlyn changed after it was copied; stop its writer and resume")
1531
+ gref(receipt, "worktree", "remove", str(moved))
1532
+
1533
+
1534
+ def cleanup_workspace(receipt, path, *, warnings, anchor=None):
1421
1535
  sha = receipt["publish_sha"]
1422
1536
  branch_ref = "refs/heads/" + receipt["branch"]
1423
1537
  work = Path(receipt["worktree"])
@@ -1434,21 +1548,7 @@ def cleanup_workspace(receipt, path, *, warnings):
1434
1548
  partial = storage_call("managed_receipt", receipt) and "tree_removal_digest" in receipt["storage"]
1435
1549
  if not partial:
1436
1550
  removable(receipt, work)
1437
- records = {} if partial else records_manifest(work)
1438
- if records:
1439
- destination = path.parent / "records"
1440
- if os.path.lexists(destination) and records_manifest(destination) != records:
1441
- digest = hashlib.sha256(json.dumps(records, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
1442
- destination = path.parent / ("records." + digest)
1443
- if not os.path.lexists(destination):
1444
- with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
1445
- shutil.copytree(safe_path(work, ".devlyn"), Path(temporary) / ".devlyn", symlinks=True)
1446
- require(records_manifest(Path(temporary)) == records, ".devlyn changed while it was copied; stop its writer and resume")
1447
- Path(temporary).rename(destination)
1448
- require(records_manifest(destination) == records, f"records copy mismatch; repair and resume: {destination}")
1449
- if storage_call("managed_receipt", receipt):
1450
- receipt["storage"]["records_custody"] = {"path": str(destination), "files": records}
1451
- atomic_json(path, receipt)
1551
+ records = {} if partial else preserve_records(receipt, path, work)
1452
1552
  if not partial:
1453
1553
  removable(receipt, work)
1454
1554
  require(records_manifest(work) == records, ".devlyn changed after it was copied; stop its writer and resume")
@@ -1457,6 +1557,8 @@ def cleanup_workspace(receipt, path, *, warnings):
1457
1557
  if str(work) in registrations(receipt):
1458
1558
  remove_managed_registration(receipt)
1459
1559
  sync_surviving_directory(Path(receipt["common_gitdir"]) / "worktrees")
1560
+ elif sys.platform == "win32":
1561
+ remove_by_moving(receipt, work)
1460
1562
  else:
1461
1563
  gref(receipt, "worktree", "remove", str(work))
1462
1564
  else:
@@ -1467,6 +1569,8 @@ def cleanup_workspace(receipt, path, *, warnings):
1467
1569
  sync_surviving_directory(work.parent)
1468
1570
  sync_surviving_directory(Path(receipt["common_gitdir"]) / "worktrees")
1469
1571
  else:
1572
+ if sys.platform == "win32" and path_present(aside(work)):
1573
+ remove_moved(receipt, path, work, anchor)
1470
1574
  require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
1471
1575
  if storage_call("managed_receipt", receipt):
1472
1576
  finish_managed_removal(receipt, path, managed_registration(receipt, recovery=True))
@@ -1480,18 +1584,25 @@ def cleanup_workspace(receipt, path, *, warnings):
1480
1584
  sync_surviving_directory(Path(receipt['common_gitdir']) / 'worktrees')
1481
1585
 
1482
1586
 
1483
- def cleanup(receipt, path, pr, *, warnings=None):
1484
- warnings = [] if warnings is None else warnings
1587
+ def verify_delivery(receipt, path, pr):
1588
+ """Evidence that the accepted source is delivered, checked before anything is removed."""
1485
1589
  if pr is not None:
1486
1590
  require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
1487
1591
  sha = receipt["publish_sha"]
1488
- branch_ref = "refs/heads/" + receipt["branch"]
1489
1592
  # The recovery ref keeps the accepted commit reachable, so squash and
1490
1593
  # rebase merges need only the merge commit on base.
1491
1594
  verify_custody(receipt, path)
1492
1595
  gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
1493
1596
  gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
1494
- cleanup_workspace(receipt, path, warnings=warnings)
1597
+ # The base was fetched through the remote's name, so that name must still bind the receipt's repository.
1598
+ require(remote_url(receipt) == receipt["remote_url"], "remote changed during the delivery checks; retain resources")
1599
+
1600
+
1601
+ def remove_delivered(receipt, path, *, warnings=None, anchor=None):
1602
+ warnings = [] if warnings is None else warnings
1603
+ sha = receipt["publish_sha"]
1604
+ branch_ref = "refs/heads/" + receipt["branch"]
1605
+ cleanup_workspace(receipt, path, warnings=warnings, anchor=anchor)
1495
1606
  checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
1496
1607
  require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
1497
1608
  # Check ownership/ref identity once more immediately before each deletion.
@@ -1511,6 +1622,11 @@ def cleanup(receipt, path, pr, *, warnings=None):
1511
1622
  atomic_json(path, receipt)
1512
1623
 
1513
1624
 
1625
+ def cleanup(receipt, path, pr, *, warnings=None, anchor=None):
1626
+ verify_delivery(receipt, path, pr)
1627
+ remove_delivered(receipt, path, warnings=warnings, anchor=anchor)
1628
+
1629
+
1514
1630
  def resolve_receipt(path, *, operation_id=None):
1515
1631
  """Resolve a lost retirement reply without guessing between operations."""
1516
1632
  require(path.name == 'receipt.json' and path == path.resolve(), 'receipt locator must be nonsymlink and absolute')
@@ -1630,13 +1746,17 @@ def completion_result(receipt, path, status, *, warnings=None):
1630
1746
 
1631
1747
 
1632
1748
  def cleanup_delivered(receipt, path, pr, *, warnings=None):
1749
+ # Missing evidence leaves delivery unrecorded; once it holds, a refused removal retains what it guards.
1750
+ verify_delivery(receipt, path, pr)
1633
1751
  try:
1634
- cleanup(receipt, path, pr, warnings=warnings)
1752
+ remove_delivered(receipt, path, warnings=warnings)
1635
1753
  except (CompletionError, OSError) as error:
1636
- managed_outage = storage_call('managed_receipt', receipt)
1637
- if not isinstance(error, (WritersUnobservable, WriterActive)) and not managed_outage:
1754
+ if receipt.get('status') == 'COMPLETE':
1755
+ # Every workspace and ref removal finished and only the final record write failed: the resume ends COMPLETE,
1756
+ # or CLEANUP_PENDING while a scratch cleanup retained on its own remains.
1638
1757
  raise
1639
- reason = manual_removal(receipt, path, error) if managed_outage and isinstance(error, WritersUnobservable) else str(error)
1758
+ managed = storage_call('managed_receipt', receipt)
1759
+ reason = manual_removal(receipt, path, error) if managed and isinstance(error, WritersUnobservable) else str(error)
1640
1760
  receipt['workspace_cleanup'] = {'status': 'RETAINED', 'reason': reason, 'resume': receipt_resume('complete', path, receipt)}
1641
1761
  atomic_json(path, receipt)
1642
1762
 
@@ -1681,7 +1801,14 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
1681
1801
  if storage_call("managed_receipt", receipt):
1682
1802
  cleanup_delivered(receipt, path, pr, warnings=warnings)
1683
1803
  else:
1684
- cleanup(receipt, path, pr, warnings=warnings)
1804
+ try:
1805
+ cleanup(receipt, path, pr, warnings=warnings, anchor=anchor)
1806
+ except (CompletionError, OSError) as error:
1807
+ if "workspace_cleanup" in receipt:
1808
+ # A retained cleanup names its latest refusal, which may guard less than the first.
1809
+ receipt["workspace_cleanup"]["reason"] = str(error)
1810
+ atomic_json(path, receipt)
1811
+ raise
1685
1812
  results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
1686
1813
  except (Exception, SystemExit) as error:
1687
1814
  results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
@@ -1794,7 +1921,7 @@ def complete(args, *, warnings=None):
1794
1921
  inspect_workspace(receipt)
1795
1922
  verify_files(Path(receipt["worktree"]), receipt["files"])
1796
1923
  require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
1797
- refused = None
1924
+ refused = waits = None
1798
1925
  if not pr.get("autoMergeRequest"):
1799
1926
  if not info["mergeCommitAllowed"]:
1800
1927
  refused = "repository disallows merge commits"
@@ -1818,9 +1945,11 @@ def complete(args, *, warnings=None):
1818
1945
  break
1819
1946
  if attempt < 9:
1820
1947
  time.sleep(2)
1821
- if state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
1948
+ if state in {"BLOCKED", "UNKNOWN"}:
1949
+ waits = state
1950
+ elif state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
1822
1951
  refused = "pull request is not mergeable: " + state
1823
- if not refused:
1952
+ if not refused and not waits:
1824
1953
  try:
1825
1954
  gh(receipt, "pr", "merge", str(pr["number"]), *auto, "--merge", "--match-head-commit", receipt["publish_sha"])
1826
1955
  except CompletionError as error:
@@ -1828,8 +1957,10 @@ def complete(args, *, warnings=None):
1828
1957
  pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
1829
1958
  validate_pr(receipt, pr)
1830
1959
  if pr["state"] != "MERGED":
1831
- # Merge only when the repository allows it; a refused request leaves the PR for a person.
1832
- return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
1960
+ # Only a refusal leaves the PR for a person; merge_waits: the resume merges once the PR is mergeable.
1961
+ if refused and not pr.get("autoMergeRequest"):
1962
+ return dict(result("PR"), merge_refused=refused)
1963
+ return dict(result("PENDING"), **({"merge_waits": waits} if waits else {}))
1833
1964
  if pr:
1834
1965
  receipt["merge"] = pr
1835
1966
  atomic_json(path, receipt)
@@ -2089,6 +2220,9 @@ class CompletionTests(unittest.TestCase):
2089
2220
  self.bin.mkdir()
2090
2221
  self.data = self.root / "gh.json"
2091
2222
  self.data.write_text(json.dumps({"bare": str(self.bare)}), encoding="utf-8")
2223
+ # A commit's detached automatic maintenance would still write into .git while a test reads the tree. A file, not
2224
+ # GIT_CONFIG_COUNT: the storage suite reuses this fixture with a native helper that refuses that variable.
2225
+ (self.root / "gitconfig").write_text("[maintenance]\n\tauto = false\n", encoding="utf-8")
2092
2226
  self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
2093
2227
  "GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
2094
2228
  "GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
@@ -2144,6 +2278,14 @@ class CompletionTests(unittest.TestCase):
2144
2278
  repository="test/project", base="main", remote="origin", worktree=str(worktree),
2145
2279
  local_base=None, from_receipt=None, start=None)
2146
2280
 
2281
+ def test_fixture_commit_starts_no_automatic_maintenance(self):
2282
+ # Its detached child would still write into .git while a test reads the tree or cleanup removes it.
2283
+ trace = self.root / "trace.json"
2284
+ self.env["GIT_TRACE2_EVENT"] = str(trace)
2285
+ self.g("commit", "-q", "--allow-empty", "-m", "traced")
2286
+ children = [line for line in trace.read_text(encoding="utf-8").splitlines() if '"event":"child_start"' in line]
2287
+ self.assertEqual(children, [])
2288
+
2147
2289
  def test_allocation_marks_exclusively_created_empty_target_before_checkout(self):
2148
2290
  # Prediction: persisted intent precedes exclusive creation and marking; real Git then populates that directory.
2149
2291
  from unittest.mock import patch
@@ -2988,6 +3130,20 @@ ctypes.CDLL(None).pthread_exit(None)
2988
3130
  clean_scratch(receipt, self.receipt, True)
2989
3131
  self.assertEqual((scratch / "keep").read_text(), "retained")
2990
3132
 
3133
+ def test_windows_empty_scratch_is_clean_and_scratch_with_files_is_retained(self):
3134
+ # Prediction: on win32, where safe scratch removal is unsupported, an empty scratch records CLEAN with 0 bytes and
3135
+ # one that holds a file stays with it. Before: both refused.
3136
+ from unittest.mock import patch
3137
+ scratch = Path(self.allocate()["scratch"])
3138
+ receipt = read_json(self.receipt)
3139
+ clean = {"status": "CLEAN", "logical_bytes_removed": 0}
3140
+ with patch.object(sys, "platform", "win32"), patch.object(shutil.rmtree, "avoids_symlink_attacks", False):
3141
+ self.assertEqual((clean_scratch(receipt, self.receipt, True), read_json(self.receipt)["scratch_cleanup"]), (clean, clean))
3142
+ (scratch / "build").write_bytes(b"output")
3143
+ with self.assertRaisesRegex(CompletionError, "safe scratch removal unsupported"):
3144
+ clean_scratch(receipt, self.receipt, True)
3145
+ self.assertEqual((scratch / "build").read_bytes(), b"output")
3146
+
2991
3147
  def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
2992
3148
  from unittest.mock import patch
2993
3149
  target = "/task/scratch/name on inside\\040literal"
@@ -3118,7 +3274,7 @@ ctypes.CDLL(None).pthread_exit(None)
3118
3274
  error.storage_error = {'code': 'storage_unavailable'}
3119
3275
  raise error
3120
3276
  args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3121
- with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}, cleanup=outage):
3277
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}, remove_delivered=outage):
3122
3278
  result = complete(args)
3123
3279
  self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
3124
3280
  self.assertIn('complete --receipt', result['resume'])
@@ -3133,13 +3289,17 @@ ctypes.CDLL(None).pthread_exit(None)
3133
3289
  receipt.update(mode='managed', allocation='managed_owned', delivery='COMPLETE', writers_released=True,
3134
3290
  storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3135
3291
  atomic_json(self.receipt, receipt)
3292
+ calls = []
3136
3293
  def outage(*args, **kwargs):
3294
+ calls.append(args)
3137
3295
  error = CompletionError('authority offline during reconciliation')
3138
3296
  error.storage_error = {'code': 'storage_unavailable'}
3139
3297
  raise error
3140
- with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {}, cleanup=outage):
3298
+ with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {}, remove_delivered=outage):
3141
3299
  [result] = reconcile(Path(receipt['common_gitdir']), self.receipt.with_name('other.json'), self.work)
3300
+ self.assertEqual(len(calls), 1)
3142
3301
  self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
3302
+ self.assertEqual(result['workspace_cleanup']['reason'], 'authority offline during reconciliation')
3143
3303
  self.assertIn('complete --receipt', result['resume'])
3144
3304
  self.assertTrue(self.task.exists())
3145
3305
 
@@ -3164,6 +3324,42 @@ ctypes.CDLL(None).pthread_exit(None)
3164
3324
  d = read_json(self.data)
3165
3325
  self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
3166
3326
 
3327
+ def test_unchanged_failed_final_receipt_write_raises_and_resumes_to_complete(self):
3328
+ # Prediction: every removal finished and only the final status-COMPLETE record write fails, so complete raises
3329
+ # that error and the printed resume ends COMPLETE. Before: retained as CLEANUP_PENDING on a COMPLETE receipt,
3330
+ # which every resume reported again.
3331
+ from unittest.mock import patch
3332
+ self.allocate(); self.accept(unchanged=True)
3333
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
3334
+ real, failed = atomic_json, []
3335
+ def final_write_fails(path, value):
3336
+ real(path, value)
3337
+ if value.get("status") == "COMPLETE" and "workspace_cleanup" not in value and not failed:
3338
+ failed.append(path)
3339
+ raise OSError(5, "fixture final receipt write failure")
3340
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3341
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, atomic_json=final_write_fails):
3342
+ with self.assertRaises(OSError):
3343
+ complete(args)
3344
+ self.assertFalse(self.task.exists())
3345
+ result, _ = self.complete("--writers-stopped", acceptance=False)
3346
+ self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
3347
+
3348
+ def test_unchanged_removal_os_error_is_retained_with_delivery_complete(self):
3349
+ # Prediction: an OSError raised while the workspace is removed (the records copy) is retained like a refusal:
3350
+ # delivery COMPLETE, CLEANUP_PENDING naming the error, tree and task ref kept. Fails if only CompletionError is retained.
3351
+ from unittest.mock import patch
3352
+ self.allocate(); self.accept(unchanged=True)
3353
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
3354
+ with patch.dict(os.environ, self.env), \
3355
+ patch.object(shutil, "copytree", side_effect=OSError(errno.ENOSPC, "fixture records copy failure")):
3356
+ result = complete(args)
3357
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3358
+ self.assertIn("fixture records copy failure", result["workspace_cleanup"]["reason"])
3359
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3360
+ self.assertTrue(self.task.exists())
3361
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3362
+
3167
3363
  def test_unchanged_writer_retention_reconciles_on_allocate(self):
3168
3364
  # Prediction: the next allocation retries on-base delivery once writer observation succeeds.
3169
3365
  from unittest.mock import Mock, patch
@@ -3232,7 +3428,8 @@ ctypes.CDLL(None).pthread_exit(None)
3232
3428
  self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
3233
3429
 
3234
3430
  def test_merged_foreign_remote_head_records_merge_and_retains_ref(self):
3235
- # Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard.
3431
+ # Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard, which keeps both
3432
+ # task refs and leaves the merged delivery COMPLETE (CLEANUP_PENDING). Before: BLOCKED, delivery unrecorded.
3236
3433
  self.allocate(); self.accept()
3237
3434
  self.complete("--mode", "pr")
3238
3435
  self.run_cmd(["gh", "pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
@@ -3240,11 +3437,113 @@ ctypes.CDLL(None).pthread_exit(None)
3240
3437
  self.g("commit", "--allow-empty", "-m", "foreign commit")
3241
3438
  foreign = self.g("rev-parse", "HEAD")
3242
3439
  self.g("push", "origin", "+" + foreign + ":refs/heads/task/fixture")
3243
- result, _ = self.complete("--writers-stopped", success=False)
3244
- self.assertEqual(result["status"], "BLOCKED")
3245
- self.assertIn("remote branch changed after merge; retain ref", result["reason"])
3440
+ result, _ = self.complete("--writers-stopped")
3441
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3442
+ self.assertIn("remote branch changed after merge; retain ref", result["workspace_cleanup"]["reason"])
3246
3443
  self.assertEqual(read_json(self.receipt)["merge"], read_json(self.data)["prs"][0])
3247
3444
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], foreign)
3445
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3446
+
3447
+ def test_merged_task_branch_checked_out_elsewhere_keeps_both_task_refs(self):
3448
+ # Prediction: with the task branch also checked out in another worktree, the merged delivery is COMPLETE and
3449
+ # reports CLEANUP_PENDING naming that worktree; neither task ref is deleted. Once that worktree leaves the branch,
3450
+ # the printed resume deletes both. Before: BLOCKED, delivery unrecorded.
3451
+ self.allocate(); self.accept()
3452
+ self.complete("--mode", "pr", "--writers-stopped")
3453
+ self.merge_pr()
3454
+ other = self.root / "other"
3455
+ self.g("worktree", "add", "--force", str(other), "task/fixture")
3456
+ result, _ = self.complete()
3457
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3458
+ self.assertIn("task branch is still checked out at " + str(other), result["workspace_cleanup"]["reason"])
3459
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3460
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3461
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3462
+ self.g("switch", "--detach", work=other)
3463
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
3464
+ self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
3465
+ self.assertFalse(self.task.exists())
3466
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3467
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
3468
+
3469
+ def test_delivery_evidence_failure_blocks_with_delivery_unrecorded(self):
3470
+ # Prediction: evidence is checked before any removal, so a PR view that is not merge evidence, or a changed
3471
+ # custody manifest, stops completion BLOCKED with delivery left at PR and nothing removed. Unchanged on origin/main;
3472
+ # fails if the evidence checks move inside the retention boundary.
3473
+ self.allocate(); self.accept()
3474
+ self.assertEqual(self.complete("--mode", "pr", "--writers-stopped")[0]["status"], "PR")
3475
+ pr = read_json(self.data)["prs"][0]
3476
+ manifest = self.receipt.parent / "manifest.json"
3477
+ for case, reason in (("merge evidence", "actual matching merge evidence is required"),
3478
+ ("custody manifest", "custody manifest changed")):
3479
+ with self.subTest(case=case):
3480
+ if case == "merge evidence":
3481
+ self.configure(prs=[dict(pr, state="MERGED", mergedAt=None, mergeCommit=None)])
3482
+ else:
3483
+ self.configure(prs=[pr])
3484
+ self.merge_pr()
3485
+ manifest.write_text("{}\n", encoding="utf-8")
3486
+ result, r = self.complete(success=False)
3487
+ self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
3488
+ self.assertIn(reason, result["reason"])
3489
+ receipt = read_json(self.receipt)
3490
+ self.assertEqual((receipt["delivery"], receipt.get("status")), ("PR", None))
3491
+ self.assertNotIn("workspace_cleanup", receipt)
3492
+ self.assertTrue(self.task.exists())
3493
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3494
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3495
+
3496
+ def test_managed_delivery_evidence_failure_raises_with_delivery_unrecorded(self):
3497
+ # Prediction: a managed receipt's changed custody manifest also raises before any removal, delivery unrecorded.
3498
+ # Before: retained as CLEANUP_PENDING with delivery COMPLETE.
3499
+ from unittest.mock import patch
3500
+ self.allocate(); self.accept(unchanged=True)
3501
+ self.cli('accept', '--receipt', self.receipt, '--acceptance', self.acceptance)
3502
+ receipt = read_json(self.receipt)
3503
+ receipt.update(mode='managed', allocation='managed_owned', storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {
3504
+ 'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3505
+ atomic_json(self.receipt, receipt)
3506
+ (self.receipt.parent / 'manifest.json').write_text('{}\n', encoding='utf-8')
3507
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3508
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}):
3509
+ with self.assertRaisesRegex(CompletionError, 'custody manifest changed'):
3510
+ complete(args)
3511
+ receipt = read_json(self.receipt)
3512
+ self.assertEqual((receipt.get('delivery'), receipt.get('status')), (None, None))
3513
+ self.assertNotIn('workspace_cleanup', receipt)
3514
+ self.assertTrue(self.task.exists())
3515
+
3516
+ def test_remote_retargeted_during_delivery_checks_blocks_with_delivery_unrecorded(self):
3517
+ # Prediction: origin is retargeted, after the first URL check, to another repository whose base holds the accepted
3518
+ # commit, so both ancestry checks pass against it; the URL recheck after them stops completion BLOCKED with delivery
3519
+ # unrecorded and nothing removed. Before: the tree was removed, the deletion-time URL refusal was retained and
3520
+ # delivery was recorded COMPLETE for the receipt's repository, whose base lacks the commit.
3521
+ from unittest.mock import patch
3522
+ self.allocate(); self.accept()
3523
+ fork = self.root / "fork.git"
3524
+ self.run_cmd(["git", "clone", "--bare", "--quiet", str(self.bare), str(fork)])
3525
+ self.g("push", str(fork), self.sha + ":refs/heads/main", work=self.task)
3526
+ bare = ["git", "--git-dir", str(self.bare)]
3527
+ base = self.run_cmd([*bare, "rev-parse", "refs/heads/main"]).stdout.strip()
3528
+ real, retargeted = repo_policy, []
3529
+ def retarget(receipt, **kwargs):
3530
+ if not retargeted:
3531
+ self.g("remote", "set-url", "origin", str(fork))
3532
+ retargeted.append(str(fork))
3533
+ return real(receipt, **kwargs)
3534
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
3535
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, repo_policy=retarget):
3536
+ with self.assertRaisesRegex(CompletionError, "remote must unambiguously identify one GitHub repository"):
3537
+ complete(args)
3538
+ self.assertEqual(retargeted, [str(fork)])
3539
+ receipt = read_json(self.receipt)
3540
+ self.assertEqual((receipt.get("delivery"), receipt.get("status")), (None, None))
3541
+ self.assertNotIn("workspace_cleanup", receipt)
3542
+ self.assertTrue(self.task.exists())
3543
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3544
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
3545
+ self.assertEqual(self.run_cmd([*bare, "rev-parse", "refs/heads/main"]).stdout.strip(), base)
3546
+ self.assertEqual(self.run_cmd([*bare, "for-each-ref", "refs/heads/task/"]).stdout, "")
3248
3547
 
3249
3548
  def test_unchanged_foreign_remote_head_retains_worktree(self):
3250
3549
  # Prediction: a foreign remote task head refuses completion before worktree removal.
@@ -3416,6 +3715,9 @@ ctypes.CDLL(None).pthread_exit(None)
3416
3715
 
3417
3716
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
3418
3717
  def test_nested_repository_or_worktree_retains_tree(self):
3718
+ # Prediction: the first call merges; a nested worktree, then a nested repository, under an ignored path leaves the
3719
+ # merged delivery COMPLETE (CLEANUP_PENDING) naming each, with the tree, the nested work and both task refs kept;
3720
+ # once both are gone the printed resume removes everything. Before: BLOCKED, delivery unrecorded.
3419
3721
  self.allocate(); self.accept()
3420
3722
  _, r = self.cli("allocate", "--repo", self.work, "--task", "inner", "--branch", "task/inner", "--repository", "test/project",
3421
3723
  "--base", "main", "--worktree", self.task / "ignored" / "inner", success=False)
@@ -3425,17 +3727,25 @@ ctypes.CDLL(None).pthread_exit(None)
3425
3727
  (nested / "product").write_text("unsaved nested work\n", encoding="utf-8")
3426
3728
  clone = self.task / "ignored" / "clone"
3427
3729
  self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
3428
- for remove in (lambda: self.g("worktree", "remove", "--force", str(nested)), lambda: shutil.rmtree(clone)):
3429
- _, r = self.complete("--mode", "auto", "--writers-stopped", success=False)
3430
- self.assertIn("nested repository or worktree", json.loads(r.stdout)["reason"])
3730
+ for name, remove in (("ignored/nested/", lambda: self.g("worktree", "remove", "--force", str(nested))),
3731
+ ("ignored/clone/", lambda: shutil.rmtree(clone))):
3732
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
3733
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3734
+ self.assertIn("nested repository or worktree inside the task tree: ", result["workspace_cleanup"]["reason"])
3735
+ self.assertIn(name, result["workspace_cleanup"]["reason"])
3736
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3431
3737
  self.assertTrue(self.task.exists())
3432
- self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3738
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3739
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3433
3740
  if nested.exists():
3434
3741
  self.assertEqual((nested / "product").read_text(encoding="utf-8"), "unsaved nested work\n")
3435
3742
  remove()
3436
- result, _ = self.complete("--mode", "auto", "--writers-stopped")
3437
- self.assertEqual(result["status"], "COMPLETE")
3743
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
3744
+ self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
3438
3745
  self.assertFalse(self.task.exists())
3746
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3747
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
3748
+ self.assertEqual(read_json(self.data)["merges"], 1)
3439
3749
 
3440
3750
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
3441
3751
  def test_default_mode_merges_when_possible_and_cleans(self):
@@ -3446,22 +3756,322 @@ ctypes.CDLL(None).pthread_exit(None)
3446
3756
  self.assertFalse(self.task.exists())
3447
3757
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3448
3758
 
3449
- def test_unobservable_writers_settle_a_merged_delivery_and_retain_the_workspace(self):
3450
- from unittest.mock import patch
3759
+ def merged(self, *, released=True):
3451
3760
  self.allocate(); self.accept()
3452
- self.complete("--mode", "pr", "--writers-stopped")
3761
+ self.complete("--mode", "pr", *(["--writers-stopped"] if released else []))
3453
3762
  self.merge_pr()
3454
- args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
3455
- with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env):
3456
- result = complete(args)
3763
+ return self.task.with_name(self.task.name + ".devlyn-removing")
3764
+
3765
+ def complete_on_windows(self, rename=None, *, writers_stopped=False, **names):
3766
+ # As native Windows reports itself; `rename` replaces os.rename for the task tree only.
3767
+ from unittest.mock import patch
3768
+ real = os.rename
3769
+ def tree_rename(source, target, *args, **kwargs):
3770
+ if rename and Path(source) == self.task:
3771
+ return rename(source, target)
3772
+ return real(source, target, *args, **kwargs)
3773
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False,
3774
+ writers_stopped=writers_stopped)
3775
+ with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env), \
3776
+ patch.object(os, "rename", tree_rename), patch.dict(complete.__globals__, names):
3777
+ return complete(args)
3778
+
3779
+ def kill_after_the_move(self):
3780
+ move = os.rename
3781
+ def killed(source, target):
3782
+ move(source, target)
3783
+ raise RuntimeError("fixture kill after the move")
3784
+ with self.assertRaisesRegex(RuntimeError, "fixture kill"):
3785
+ self.complete_on_windows(killed)
3786
+
3787
+ def assert_windows_removal_completed(self, result, moved):
3788
+ self.assertEqual((result["status"], result["delivery_status"], result["workspace_cleanup"]), ("COMPLETE", "COMPLETE", None))
3789
+ self.assertFalse(os.path.lexists(self.task) or os.path.lexists(moved))
3790
+ self.assertFalse({str(self.task), str(moved)} & set(registrations(read_json(self.receipt))))
3791
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3792
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
3793
+
3794
+ def assert_windows_removal_retained(self, result, reason):
3795
+ # A refused removal of a merged delivery: delivery COMPLETE, the refusal and its resume recorded, both task refs kept.
3457
3796
  self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3458
- self.assertIn("writer observation unsupported", result["workspace_cleanup"]["reason"])
3459
- receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
3460
- self.assertEqual((receipt["delivery"], receipt.get("status")), ("COMPLETE", None))
3461
- self.assertTrue(self.task.exists())
3462
- self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3797
+ self.assertIn(reason, result["workspace_cleanup"]["reason"])
3798
+ self.assertIn("complete --receipt", result["resume"])
3799
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3800
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3463
3801
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3464
3802
 
3803
+ def test_windows_removal_waits_while_the_move_is_refused_and_completes_once_it_succeeds(self):
3804
+ # Prediction: on win32 a merged delivery's tree is moved aside, then removed. A refused move keeps delivery
3805
+ # COMPLETE and the tree, its registration and both task branches, with the in-use reason and the Windows error;
3806
+ # the resume then moves and removes it. A Git without `worktree repair` or GIT_CONFIG_COUNT (2.30) keeps the tree
3807
+ # without a move, naming the version needed. stopped_writers itself stays unsupported there.
3808
+ # Before: every win32 removal was retained as "writer observation unsupported", without a move.
3809
+ from unittest.mock import patch
3810
+ moved = self.merged()
3811
+ attempts = []
3812
+ run = command
3813
+ def older(argv, **kwargs):
3814
+ return "git version 2.30.0" if argv == ["git", "--version"] else run(argv, **kwargs)
3815
+ result = self.complete_on_windows(lambda source, target: attempts.append(target), command=older)
3816
+ self.assertEqual((result["status"], attempts), ("CLEANUP_PENDING", []))
3817
+ self.assertEqual(result["workspace_cleanup"]["reason"],
3818
+ "removing a task checkout on Windows needs Git 2.31 or later; update Git, then resume")
3819
+ def held(source, target):
3820
+ attempts.append(target)
3821
+ raise PermissionError(13, "fixture: the process cannot access the file because it is being used by another process")
3822
+ result = self.complete_on_windows(held)
3823
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3824
+ self.assertIn(f"a program still uses {self.task}", result["workspace_cleanup"]["reason"])
3825
+ self.assertIn("being used by another process", result["workspace_cleanup"]["reason"])
3826
+ self.assertEqual(attempts, [moved])
3827
+ self.assertTrue(self.task.is_dir() and not os.path.lexists(moved))
3828
+ self.assertIn(str(self.task), registrations(read_json(self.receipt)))
3829
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3830
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3831
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3832
+ with patch.object(sys, "platform", "win32"), self.assertRaisesRegex(WritersUnobservable, "unsupported on this platform"):
3833
+ stopped_writers(self.root)
3834
+
3835
+ def test_windows_removal_refuses_populated_submodules_before_any_move(self):
3836
+ # Prediction: Git's unforced removal would refuse a populated submodule after the move, so it is refused while the
3837
+ # tree is in place: CLEANUP_PENDING with delivery COMPLETE naming the retained module repositories, never moved,
3838
+ # nothing deleted. Before (this branch on the old base): BLOCKED with delivery unrecorded.
3839
+ module = self.root / "module"
3840
+ self.run_cmd(["git", "init", "--initial-branch=main", str(module)])
3841
+ self.g("commit", "--allow-empty", "-m", "module", work=module)
3842
+ self.g("submodule", "add", str(module), "module")
3843
+ self.g("commit", "-m", "add module")
3844
+ self.g("push", "origin", "main")
3845
+ moved = self.merged()
3846
+ self.g("submodule", "update", "--init", work=self.task)
3847
+ attempts = []
3848
+ self.assert_windows_removal_retained(self.complete_on_windows(lambda source, target: attempts.append(target)),
3849
+ "retained populated submodules repositories block removal")
3850
+ self.assertEqual(attempts, [])
3851
+ self.assertTrue((self.task / "module/.git").exists() and not os.path.lexists(moved))
3852
+
3853
+ def test_windows_removal_refuses_a_destination_that_overlaps_a_registration_before_any_move(self):
3854
+ # Prediction: a registration at the moved name, its folder absent, is refused as allocation refuses an overlap:
3855
+ # CLEANUP_PENDING with delivery COMPLETE naming that path, never moved; once it is pruned the resume removes the
3856
+ # tree. Before (this branch on the old base): BLOCKED with delivery unrecorded.
3857
+ moved = self.merged()
3858
+ self.g("worktree", "add", "-b", "other", str(moved), "main")
3859
+ shutil.rmtree(moved)
3860
+ attempts = []
3861
+ self.assert_windows_removal_retained(self.complete_on_windows(lambda source, target: attempts.append(target)),
3862
+ f"{moved} exists or overlaps a registered worktree")
3863
+ self.assertEqual(attempts, [])
3864
+ self.assertTrue(self.task.is_dir() and not os.path.lexists(moved))
3865
+ self.g("worktree", "prune")
3866
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3867
+
3868
+ def test_windows_removal_finishes_after_a_kill_following_the_move(self):
3869
+ # Prediction: a kill after the move leaves the tree moved and still registered at its original path; the next
3870
+ # completion repairs, checks and removes it. Before: no move happened, so no kill.
3871
+ moved = self.merged()
3872
+ self.kill_after_the_move()
3873
+ self.assertTrue(moved.is_dir() and not os.path.lexists(self.task))
3874
+ self.assertIn(str(self.task), registrations(read_json(self.receipt)))
3875
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3876
+
3877
+ def test_windows_removal_finishes_after_a_kill_following_the_repair(self):
3878
+ # Prediction: a kill after the repair leaves the moved tree registered at its new path only; the next completion
3879
+ # removes it. Before: no repair ran, so no kill.
3880
+ moved = self.merged()
3881
+ run = gref
3882
+ def killed(receipt, *args):
3883
+ output = run(receipt, *args)
3884
+ if args[:2] == ("worktree", "repair"):
3885
+ raise RuntimeError("fixture kill after the repair")
3886
+ return output
3887
+ with self.assertRaisesRegex(RuntimeError, "fixture kill"):
3888
+ self.complete_on_windows(gref=killed)
3889
+ rows = registrations(read_json(self.receipt))
3890
+ self.assertTrue(moved.is_dir() and str(moved) in rows and str(self.task) not in rows)
3891
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3892
+
3893
+ def test_windows_removal_refuses_a_foreign_folder_of_the_moved_name(self):
3894
+ # Prediction: a folder of the moved name that is not this checkout is refused with both paths named and is
3895
+ # never touched, whether the checkout is still in place or was moved elsewhere by a person: each time
3896
+ # CLEANUP_PENDING with delivery COMPLETE. Once the checkout is back and that folder gone, the resume removes it.
3897
+ # Before (this branch on the old base): BLOCKED with delivery unrecorded.
3898
+ moved = self.merged()
3899
+ moved.mkdir()
3900
+ (moved / "foreign").write_text("keep", encoding="utf-8")
3901
+ attempts = []
3902
+ self.assert_windows_removal_retained(self.complete_on_windows(lambda source, target: attempts.append(target)),
3903
+ f"{moved} exists or overlaps a registered worktree; leave it, keep {self.task}")
3904
+ self.assertEqual(attempts, [])
3905
+ self.assertTrue(self.task.is_dir())
3906
+ elsewhere = self.task.with_name("elsewhere")
3907
+ self.task.rename(elsewhere)
3908
+ (moved / ".git").write_text(f"gitdir: {self.root / 'foreign.git'}\n", encoding="utf-8")
3909
+ self.assert_windows_removal_retained(self.complete_on_windows(),
3910
+ f"{moved} cannot be verified as the task checkout moved from {self.task}")
3911
+ self.assertEqual(sorted(path.name for path in moved.iterdir()), [".git", "foreign"])
3912
+ self.assertTrue((elsewhere / "product").is_file())
3913
+ shutil.rmtree(moved)
3914
+ elsewhere.rename(self.task)
3915
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3916
+
3917
+ def test_windows_removal_after_a_kill_never_deletes_a_record_saved_unseen(self):
3918
+ # Prediction: a record saved into the moved tree after a kill is copied beside the receipt before removal; a
3919
+ # symlink there refuses the removal with the tree kept (CLEANUP_PENDING, delivery COMPLETE). Before: no move
3920
+ # happened, so no kill.
3921
+ moved = self.merged()
3922
+ self.kill_after_the_move()
3923
+ link = moved / ".devlyn/link"
3924
+ link.symlink_to("checks.txt")
3925
+ self.assert_windows_removal_retained(self.complete_on_windows(), "symlink")
3926
+ self.assertTrue((moved / ".devlyn/checks.txt").is_file() and link.is_symlink())
3927
+ link.unlink()
3928
+ (moved / ".devlyn/late.txt").write_text("saved after the kill\n", encoding="utf-8")
3929
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3930
+ self.assertEqual([path.read_text(encoding="utf-8") for path in self.receipt.parent.glob("records*/.devlyn/late.txt")],
3931
+ ["saved after the kill\n"])
3932
+
3933
+ def test_windows_recovery_needs_the_owners_recorded_release(self):
3934
+ # Prediction: a checkout published without --writers-stopped and renamed by hand to the moved name after the merge
3935
+ # is refused by a completion without that flag, naming it, with its ignored output kept (CLEANUP_PENDING, delivery
3936
+ # COMPLETE); with the flag it is removed. Before: the recovery removed it and its ignored output without the release.
3937
+ moved = self.merged(released=False)
3938
+ (self.task / "ignored").mkdir()
3939
+ (self.task / "ignored/output").write_text("kept", encoding="utf-8")
3940
+ self.task.rename(moved)
3941
+ self.assert_windows_removal_retained(self.complete_on_windows(), "resume with --writers-stopped")
3942
+ self.assertEqual(((moved / "ignored/output").read_text(encoding="utf-8"), (moved / "product").read_text(encoding="utf-8")),
3943
+ ("kept", "accepted\n"))
3944
+ self.assert_windows_removal_completed(self.complete_on_windows(writers_stopped=True), moved)
3945
+
3946
+ def test_windows_removal_gives_every_git_call_on_the_moved_tree_long_paths(self):
3947
+ # Prediction: after the move, every Git call on the moved tree, in the removal (repair, remove) and in its recovery
3948
+ # (repair, status, ls-files, remove), has core.longpaths=true in its environment after the entries already there;
3949
+ # calls on the tree before the move and after the removal see those entries only. The removal's own `worktree
3950
+ # remove` is recorded, then refused as Git would refuse it, so the recovery runs next. Before: only `worktree
3951
+ # remove` had it, as an argument; the recovery's status and ls-files ran without it.
3952
+ moved = self.merged()
3953
+ self.env.update(GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="fixture.kept", GIT_CONFIG_VALUE_0="yes")
3954
+ calls, refused = [], []
3955
+ run = command
3956
+ def recorded(argv, **kwargs):
3957
+ env = kwargs.get("env") or os.environ
3958
+ calls.append((argv, {name: value for name, value in env.items() if re.fullmatch(r"GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+)", name)}))
3959
+ if "remove" in argv and str(moved) in argv and not refused:
3960
+ refused.append(len(calls))
3961
+ raise CompletionError("fixture: Git refused the first removal")
3962
+ return run(argv, **kwargs)
3963
+ result = self.complete_on_windows(command=recorded)
3964
+ self.assertEqual((result["status"], refused), ("CLEANUP_PENDING", [len(calls)]))
3965
+ self.assertIn("fixture: Git refused the first removal", result["workspace_cleanup"]["reason"])
3966
+ self.assertEqual([word for argv, seen in calls if str(moved) in argv for word in argv if word in {"repair", "remove"}],
3967
+ ["repair", "remove"])
3968
+ self.assert_windows_removal_completed(self.complete_on_windows(command=recorded), moved)
3969
+ kept = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "fixture.kept", "GIT_CONFIG_VALUE_0": "yes"}
3970
+ longpaths = dict(kept, GIT_CONFIG_COUNT="2", GIT_CONFIG_KEY_1="core.longpaths", GIT_CONFIG_VALUE_1="true")
3971
+ on_moved = [(argv, seen) for argv, seen in calls if str(moved) in argv]
3972
+ self.assertLessEqual({"repair", "status", "ls-files", "remove"}, {word for argv, seen in on_moved for word in argv})
3973
+ self.assertEqual([argv for argv, seen in on_moved if seen != longpaths], [])
3974
+ self.assertEqual([argv for argv, seen in calls if (str(self.task) in argv or "push" in argv or "update-ref" in argv)
3975
+ and seen != kept], [])
3976
+
3977
+ def test_windows_recovery_of_a_moved_checkout_whose_registration_is_gone_is_left_to_a_person(self):
3978
+ # Prediction: after a kill following the move and `git worktree prune`, every completion retains the tree with the
3979
+ # sentence that the registration is gone (CLEANUP_PENDING, delivery COMPLETE) and deletes nothing; once the folder
3980
+ # is removed by hand the completion finishes.
3981
+ # Before: refused as "cannot be verified as the task checkout", which is not true of it.
3982
+ moved = self.merged()
3983
+ self.kill_after_the_move()
3984
+ self.g("worktree", "prune")
3985
+ gone = (f"{moved} is the task checkout moved from {self.task}, but its Git registration is gone; "
3986
+ "inspect it and remove it by hand, then resume")
3987
+ files = sorted(moved.rglob("*"))
3988
+ for attempt in range(2):
3989
+ self.assert_windows_removal_retained(self.complete_on_windows(), gone)
3990
+ self.assertEqual(sorted(moved.rglob("*")), files)
3991
+ shutil.rmtree(moved)
3992
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
3993
+
3994
+ def test_windows_recovery_never_calls_a_folder_naming_the_pruned_registration_the_task_checkout(self):
3995
+ # Prediction: the checkout moved elsewhere by a person and `git worktree prune`, then a folder at the moved name
3996
+ # whose .git names the recorded Gitdir: refused as not verifiable as the task checkout, both folders untouched
3997
+ # (CLEANUP_PENDING, delivery COMPLETE).
3998
+ # Before: called the task checkout whose registration is gone, and the reader was told to remove it by hand.
3999
+ moved = self.merged()
4000
+ gitdir = read_json(self.receipt)["worktree_gitdir"]
4001
+ elsewhere = self.task.with_name("elsewhere")
4002
+ self.task.rename(elsewhere)
4003
+ self.g("worktree", "prune")
4004
+ moved.mkdir()
4005
+ (moved / "foreign").write_text("keep", encoding="utf-8")
4006
+ (moved / ".git").write_text(f"gitdir: {gitdir}\n", encoding="utf-8")
4007
+ files = sorted(elsewhere.rglob("*"))
4008
+ foreign = f"{moved} cannot be verified as the task checkout moved from {self.task}; leave both, inspect them, then resume"
4009
+ self.assert_windows_removal_retained(self.complete_on_windows(), foreign)
4010
+ self.assertEqual(sorted(path.name for path in moved.iterdir()), [".git", "foreign"])
4011
+ self.assertEqual((sorted(elsewhere.rglob("*")), (elsewhere / "product").read_text(encoding="utf-8")), (files, "accepted\n"))
4012
+
4013
+ def test_windows_recovery_never_removes_a_moved_checkout_its_anchor_names_by_another_path(self):
4014
+ # Prediction: an anchor naming the moved checkout through a symlink is the same folder: the recovery refuses with
4015
+ # the anchor reason and keeps it. Before: the paths were compared as strings and the checkout was removed.
4016
+ from unittest.mock import patch
4017
+ moved = self.merged()
4018
+ self.kill_after_the_move()
4019
+ link = self.root / "anchor"
4020
+ link.symlink_to(moved, target_is_directory=True)
4021
+ receipt = read_json(self.receipt)
4022
+ with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env), \
4023
+ self.assertRaisesRegex(CompletionError, re.escape("anchor checkout: " + str(link))):
4024
+ cleanup(receipt, self.receipt, receipt["merge"], anchor=link)
4025
+ self.assertEqual((moved / "product").read_text(encoding="utf-8"), "accepted\n")
4026
+
4027
+ def test_windows_recovery_refuses_a_nested_repository_under_an_ignored_path_of_the_moved_tree(self):
4028
+ # Prediction: a repository with uncommitted work that appears under an ignored path of the moved tree after a kill
4029
+ # refuses the recovery ("nested repository"; CLEANUP_PENDING, delivery COMPLETE) and its file stays; once it is
4030
+ # gone the resume removes the tree. Without the moved tree's removal checks, Git's removal deletes it.
4031
+ moved = self.merged()
4032
+ self.kill_after_the_move()
4033
+ nested = moved / "ignored/nested"
4034
+ self.run_cmd(["git", "init", "--initial-branch=main", str(nested)])
4035
+ (nested / "work.txt").write_text("uncommitted\n", encoding="utf-8")
4036
+ self.assert_windows_removal_retained(self.complete_on_windows(),
4037
+ "nested repository or worktree inside the task tree: ignored/nested/")
4038
+ self.assertEqual((nested / "work.txt").read_text(encoding="utf-8"), "uncommitted\n")
4039
+ shutil.rmtree(nested)
4040
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
4041
+
4042
+ def test_windows_removal_refuses_a_gitlink_populated_by_a_plain_clone_before_any_move(self):
4043
+ # Prediction: a gitlink whose path holds a repository cloned there, with nothing under the tree's Gitdir, is
4044
+ # refused by the populated-gitlink check before any move, the tree in place (CLEANUP_PENDING, delivery COMPLETE).
4045
+ # Without that check the tree is moved and Git's unforced removal refuses it at the moved name.
4046
+ module = self.root / "module"
4047
+ self.run_cmd(["git", "init", "--initial-branch=main", str(module)])
4048
+ self.g("commit", "--allow-empty", "-m", "module", work=module)
4049
+ self.g("submodule", "add", str(module), "module")
4050
+ self.g("commit", "-m", "add module")
4051
+ self.g("push", "origin", "main")
4052
+ moved = self.merged()
4053
+ self.run_cmd(["git", "clone", "--quiet", str(module), str(self.task / "module")])
4054
+ attempts = []
4055
+ self.assert_windows_removal_retained(self.complete_on_windows(lambda source, target: attempts.append(target)),
4056
+ "working tree contains populated submodules")
4057
+ self.assertEqual(attempts, [])
4058
+ self.assertFalse(path_present(Path(read_json(self.receipt)["worktree_gitdir"]) / "modules"))
4059
+ self.assertTrue((self.task / "module/.git").is_dir() and not os.path.lexists(moved))
4060
+
4061
+ def test_windows_removal_keeps_the_tree_when_its_move_fails_for_another_reason(self):
4062
+ # Prediction: a move that fails with an OSError other than PermissionError keeps delivery COMPLETE and the tree,
4063
+ # with "cannot move ... aside" and the error; the resume then removes it. Without that branch the raw error blocks.
4064
+ moved = self.merged()
4065
+ def failed(source, target):
4066
+ raise OSError(errno.EXDEV, "fixture: not the same device")
4067
+ result = self.complete_on_windows(failed)
4068
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4069
+ self.assertIn(f"cannot move {self.task} aside to remove it", result["workspace_cleanup"]["reason"])
4070
+ self.assertIn("not the same device", result["workspace_cleanup"]["reason"])
4071
+ self.assertTrue(self.task.is_dir() and not os.path.lexists(moved))
4072
+ self.assertIn(str(self.task), registrations(read_json(self.receipt)))
4073
+ self.assert_windows_removal_completed(self.complete_on_windows(), moved)
4074
+
3465
4075
  def test_standalone_cleanup_checks_writers_before_reading_records(self):
3466
4076
  from unittest.mock import patch
3467
4077
  self.allocate(); self.accept()
@@ -3577,6 +4187,44 @@ ctypes.CDLL(None).pthread_exit(None)
3577
4187
  self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3578
4188
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3579
4189
 
4190
+ def test_reconcile_checks_delivery_evidence_before_cleanup(self):
4191
+ # Prediction: a merged standalone receipt whose recovery ref moved gives a RETAINED reconcile row naming it, with
4192
+ # the tree and both task refs kept. Fails if reconcile's standalone cleanup skips verify_delivery.
4193
+ self.allocate(); self.accept()
4194
+ self.complete("--mode", "pr", "--writers-stopped")
4195
+ self.merge_pr()
4196
+ receipt, task = self.receipt, self.task
4197
+ saved = read_json(receipt)
4198
+ self.g("update-ref", saved["recovery_ref"], saved["baseline"])
4199
+ result = self.allocate("next")
4200
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
4201
+ self.assertIn("recovery reachability changed", result["reconciled"][0]["reason"])
4202
+ self.assertTrue(task.exists())
4203
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
4204
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
4205
+
4206
+ def test_reconcile_replaces_a_retained_reason_with_its_latest_refusal(self):
4207
+ # Prediction: complete retains a merged delivery for a nested repository; once that is gone and the remote task
4208
+ # branch has moved, the next allocation's reconcile removes the tree, keeps the task ref and replaces the receipt's
4209
+ # reason with the remote-branch refusal. Before: the receipt still named the nested repository.
4210
+ self.allocate(); self.accept()
4211
+ clone = self.task / "ignored" / "clone"
4212
+ self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
4213
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
4214
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4215
+ self.assertIn("ignored/clone/", result["workspace_cleanup"]["reason"])
4216
+ shutil.rmtree(clone)
4217
+ self.g("commit", "--allow-empty", "-m", "foreign commit")
4218
+ self.g("push", "origin", "+" + self.g("rev-parse", "HEAD") + ":refs/heads/task/fixture")
4219
+ receipt, task = self.receipt, self.task
4220
+ [row] = self.allocate("next")["reconciled"]
4221
+ self.assertEqual((row["receipt"], row["status"]), (str(receipt), "RETAINED"))
4222
+ self.assertIn("remote branch changed after merge; retain ref", row["reason"])
4223
+ self.assertFalse(task.exists())
4224
+ saved = read_json(receipt)["workspace_cleanup"]
4225
+ self.assertEqual((saved["status"], saved["reason"]), ("RETAINED", row["reason"]))
4226
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
4227
+
3580
4228
  def test_reconcile_skips_open_and_unaccepted_and_retains_in_use_tree(self):
3581
4229
  self.allocate(); self.accept()
3582
4230
  self.complete("--mode", "pr", "--writers-stopped")
@@ -3650,6 +4298,23 @@ ctypes.CDLL(None).pthread_exit(None)
3650
4298
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3651
4299
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3652
4300
 
4301
+ def test_reconcile_never_removes_a_moved_checkout_an_allocation_is_anchored_in(self):
4302
+ # Prediction: on win32, after a kill following the move, an allocation anchored in the moved checkout retains it
4303
+ # with the anchor reason. Before: no move happened, so no kill.
4304
+ from unittest.mock import patch
4305
+ moved = self.merged()
4306
+ self.kill_after_the_move()
4307
+ before = self.anchor_state(moved)
4308
+ args = self.allocation_args("next", self.root / "next")
4309
+ args.repo = str(moved)
4310
+ with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env):
4311
+ result = allocate(args)
4312
+ self.assertEqual(result["status"], "ALLOCATED")
4313
+ self.assertEqual([row["status"] for row in result["reconciled"]], ["RETAINED"])
4314
+ self.assertIn("anchor checkout: " + str(moved), result["reconciled"][0]["reason"])
4315
+ self.assertEqual(self.anchor_state(moved), before)
4316
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
4317
+
3653
4318
  def test_reconcile_reports_scratch_cleanup_pending(self):
3654
4319
  scratch = Path(self.allocate()["scratch"])
3655
4320
  self.accept()
@@ -3741,16 +4406,17 @@ ctypes.CDLL(None).pthread_exit(None)
3741
4406
  other.write_bytes(b"owner data")
3742
4407
  link = self.task / ".devlyn/link"
3743
4408
  link.symlink_to(other)
3744
- result, _ = self.complete(success=False)
3745
- self.assertIn("path escapes custody", result["reason"])
4409
+ result, _ = self.complete()
4410
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4411
+ self.assertIn("path escapes custody", result["workspace_cleanup"]["reason"])
3746
4412
  self.assertTrue(self.task.exists())
3747
4413
  self.assertTrue(link.is_symlink())
3748
4414
  self.assertEqual(other.read_bytes(), b"owner data")
3749
4415
  link.unlink()
3750
4416
  if hasattr(os, "mkfifo"):
3751
4417
  os.mkfifo(link)
3752
- result, _ = self.complete(success=False)
3753
- self.assertIn("regular file", result["reason"])
4418
+ result, _ = self.complete()
4419
+ self.assertIn("regular file", result["workspace_cleanup"]["reason"])
3754
4420
  self.assertTrue(self.task.exists())
3755
4421
  link.unlink()
3756
4422
  saved = self.receipt.parent / "records/.devlyn"
@@ -3817,8 +4483,9 @@ ctypes.CDLL(None).pthread_exit(None)
3817
4483
  extra = generation / ".devlyn/unexpected"
3818
4484
  extra.write_bytes(b"keep this copy\n")
3819
4485
  mismatch = records_manifest(generation)
3820
- result, _ = self.complete(success=False)
3821
- self.assertIn(generation.name, result["reason"])
4486
+ result, _ = self.complete()
4487
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4488
+ self.assertIn(generation.name, result["workspace_cleanup"]["reason"])
3822
4489
  self.assertTrue(self.task.exists())
3823
4490
  self.assertEqual(records_manifest(generation), mismatch)
3824
4491
  self.assertEqual(records_manifest(saved), original)
@@ -3845,8 +4512,9 @@ ctypes.CDLL(None).pthread_exit(None)
3845
4512
  (work / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
3846
4513
  args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
3847
4514
  with patch.dict(os.environ, self.env), patch.dict(cleanup.__globals__, {"removable": changed}):
3848
- with self.assertRaisesRegex(CompletionError, "changed after it was copied; stop its writer and resume"):
3849
- complete(args)
4515
+ result = complete(args)
4516
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4517
+ self.assertIn("changed after it was copied; stop its writer and resume", result["workspace_cleanup"]["reason"])
3850
4518
  self.assertTrue(self.task.exists())
3851
4519
  self.assertEqual(records_manifest(saved), original)
3852
4520
  final = records_manifest(self.task)
@@ -3868,8 +4536,9 @@ ctypes.CDLL(None).pthread_exit(None)
3868
4536
  return result
3869
4537
  args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
3870
4538
  with patch.dict(os.environ, self.env), patch.object(shutil, "copytree", changed):
3871
- with self.assertRaisesRegex(CompletionError, "changed while it was copied; stop its writer and resume"):
3872
- complete(args)
4539
+ result = complete(args)
4540
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4541
+ self.assertIn("changed while it was copied; stop its writer and resume", result["workspace_cleanup"]["reason"])
3873
4542
  self.assertTrue(self.task.exists())
3874
4543
  self.assertFalse((self.receipt.parent / "records").exists())
3875
4544
  self.assertEqual(list(self.receipt.parent.glob(".records-*")), [])
@@ -3877,8 +4546,8 @@ ctypes.CDLL(None).pthread_exit(None)
3877
4546
  def test_linked_custody_and_interrupted_removal(self):
3878
4547
  self.allocate(); self.accept()
3879
4548
  self.configure(interrupt_remove=True)
3880
- result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
3881
- self.assertNotEqual(r.returncode, 0)
4549
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
4550
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3882
4551
  self.assertFalse(self.task.exists())
3883
4552
  saved = json.loads(self.receipt.read_text(encoding="utf-8"))
3884
4553
  self.assertNotIn("cleanup_started", saved)
@@ -3923,15 +4592,24 @@ ctypes.CDLL(None).pthread_exit(None)
3923
4592
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
3924
4593
 
3925
4594
  def test_retains_dirty_locked_and_active_tree(self):
4595
+ # Prediction: the first call merges; each refused removal keeps the tree and task ref and reports CLEANUP_PENDING
4596
+ # with delivery COMPLETE, while a caller inside the tree is refused (BLOCKED) before anything. Before: all BLOCKED.
3926
4597
  self.allocate(); self.accept()
4598
+ reasons = {"no-yield": "resume with --writers-stopped", "dirty": "dirty/untracked workspace",
4599
+ "untracked": "dirty/untracked workspace", "locked": "detached or locked"}
3927
4600
  for case in ("no-yield", "dirty", "untracked", "locked", "cwd"):
3928
4601
  path = self.task / ("product" if case == "dirty" else "unknown")
3929
4602
  if case in {"dirty", "untracked"}:
3930
4603
  path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
3931
4604
  if case == "locked": self.g("worktree", "lock", str(self.task))
3932
4605
  with self.subTest(case=case):
3933
- _, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
3934
- self.assertNotEqual(r.returncode, 0)
4606
+ result, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
4607
+ if case == "cwd":
4608
+ self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
4609
+ self.assertIn("caller cwd is inside", result["reason"])
4610
+ else:
4611
+ self.assertEqual((r.returncode, result["status"], result["delivery_status"]), (0, "CLEANUP_PENDING", "COMPLETE"))
4612
+ self.assertIn(reasons[case], result["workspace_cleanup"]["reason"])
3935
4613
  self.assertTrue(self.task.exists())
3936
4614
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3937
4615
  if case == "untracked": path.unlink()
@@ -4004,10 +4682,11 @@ ctypes.CDLL(None).pthread_exit(None)
4004
4682
  self.assertTrue(self.task.exists())
4005
4683
  finally:
4006
4684
  child.terminate(); child.wait(timeout=5)
4007
- # A foreign branch in the same registered tree never inherits ownership.
4685
+ # A foreign branch in the same registered tree never inherits ownership; the merged delivery stays COMPLETE.
4008
4686
  self.g("switch", "-c", "foreign", work=self.task)
4009
- _, r = self.complete("--writers-stopped", success=False)
4010
- self.assertNotEqual(r.returncode, 0)
4687
+ result, _ = self.complete("--writers-stopped")
4688
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4689
+ self.assertIn("foreign worktree branch", result["workspace_cleanup"]["reason"])
4011
4690
  self.assertTrue(self.task.exists())
4012
4691
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
4013
4692
 
@@ -4015,8 +4694,8 @@ ctypes.CDLL(None).pthread_exit(None)
4015
4694
  self.allocate(); self.accept()
4016
4695
  race = self.g("rev-parse", "main")
4017
4696
  self.configure(remote_delete_race=True, race_sha=race)
4018
- _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
4019
- self.assertNotEqual(r.returncode, 0)
4697
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
4698
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4020
4699
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
4021
4700
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
4022
4701
 
@@ -4032,16 +4711,37 @@ ctypes.CDLL(None).pthread_exit(None)
4032
4711
  self.assertEqual(d["prs"][0]["state"], "MERGED")
4033
4712
  self.assertIsNone(d["prs"][0]["autoMergeRequest"])
4034
4713
 
4035
- def test_auto_disallowed_blocked_refuses_without_merge(self):
4714
+ def test_auto_disallowed_blocked_waits_and_its_resume_merges(self):
4715
+ # Prediction (B3): with auto-merge off, required checks or reviews not passing yet (BLOCKED) make no merge call
4716
+ # and report PENDING with merge_waits; once the PR is CLEAN the printed resume merges it directly and cleans.
4717
+ # Before: PR with merge_refused "not mergeable: BLOCKED", a refusal left for a person.
4718
+ self.allocate(); self.accept()
4719
+ self.configure(auto_allowed=False, merge_state="BLOCKED")
4720
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
4721
+ self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "BLOCKED"))
4722
+ self.assertNotIn("merge_refused", result)
4723
+ d = json.loads(self.data.read_text(encoding="utf-8"))
4724
+ self.assertEqual((d.get("merge_calls", []), d["prs"][0]["state"]), ([], "OPEN"))
4725
+ self.assertEqual(read_json(self.receipt)["delivery"], "PENDING")
4726
+ self.configure(merge_state="CLEAN")
4727
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
4728
+ self.assertEqual(result["status"], "COMPLETE")
4729
+ d = json.loads(self.data.read_text(encoding="utf-8"))
4730
+ self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
4731
+ "--repo", "github.com/test/project"]])
4732
+ self.assertEqual(d["prs"][0]["state"], "MERGED")
4733
+ self.assertFalse(self.task.exists())
4734
+
4735
+ def test_auto_disallowed_unmergeable_states_refuse_without_merge(self):
4036
4736
  self.allocate(); self.accept()
4037
- for state in ("BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
4737
+ for state in ("BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
4038
4738
  with self.subTest(state=state):
4039
4739
  self.configure(auto_allowed=False, merge_state=state)
4040
4740
  result, _ = self.complete("--mode", "auto")
4041
4741
  self.assertEqual(result["status"], "PR")
4042
4742
  self.assertIn("not mergeable: " + state, result["merge_refused"])
4043
4743
  d = json.loads(self.data.read_text(encoding="utf-8"))
4044
- self.assertEqual(d["merge_state_views"], ["BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
4744
+ self.assertEqual(d["merge_state_views"], ["BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
4045
4745
  self.assertEqual(d.get("merge_calls", []), [])
4046
4746
  self.assertEqual(d["prs"][0]["state"], "OPEN")
4047
4747
  self.assertIsNone(d["prs"][0]["autoMergeRequest"])
@@ -4065,12 +4765,12 @@ ctypes.CDLL(None).pthread_exit(None)
4065
4765
  self.assertEqual(result["status"], "PENDING")
4066
4766
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["auto_setting_reads"], 1)
4067
4767
 
4068
- def test_auto_disallowed_unknown_bound_refuses_without_merge(self):
4768
+ def test_auto_disallowed_unknown_bound_waits_without_merge(self):
4069
4769
  self.allocate(); self.accept()
4070
4770
  self.configure(auto_allowed=False, merge_state="UNKNOWN")
4071
4771
  result, _ = self.complete("--mode", "auto")
4072
- self.assertEqual(result["status"], "PR")
4073
- self.assertIn("not mergeable: UNKNOWN", result["merge_refused"])
4772
+ self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "UNKNOWN"))
4773
+ self.assertNotIn("merge_refused", result)
4074
4774
  d = json.loads(self.data.read_text(encoding="utf-8"))
4075
4775
  self.assertEqual(d["merge_state_views"], ["UNKNOWN"] * 10)
4076
4776
  self.assertEqual(d.get("merge_calls", []), [])
@@ -4154,8 +4854,8 @@ ctypes.CDLL(None).pthread_exit(None)
4154
4854
  self.assertNotIn("merge_refused", result)
4155
4855
  self.configure(merge_allowed=True, auto_allowed=False, pending=True)
4156
4856
  result, _ = self.complete("--mode", "auto")
4157
- self.assertEqual(result["status"], "PR")
4158
- self.assertIn("not mergeable: BLOCKED", result["merge_refused"])
4857
+ self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "BLOCKED"))
4858
+ self.assertNotIn("merge_refused", result)
4159
4859
  self.assertTrue(self.task.exists())
4160
4860
  # Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
4161
4861
  result, _ = self.complete("--no-push", success=False)
@@ -4214,8 +4914,8 @@ ctypes.CDLL(None).pthread_exit(None)
4214
4914
  user_file.write_text("retained user data", encoding="utf-8")
4215
4915
  before = self.anchor_state()
4216
4916
  self.configure(interrupt_delete=True)
4217
- _, r = self.complete(acceptance=False, success=False)
4218
- self.assertNotEqual(r.returncode, 0)
4917
+ result, _ = self.complete(acceptance=False)
4918
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4219
4919
  self.assertEqual(self.anchor_state(), before)
4220
4920
  other = self.root / "advance"
4221
4921
  self.run_cmd(["git", "clone", str(self.bare), str(other)])