devlyn-cli 4.3.32 → 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.
@@ -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))
@@ -1494,11 +1598,11 @@ def verify_delivery(receipt, path, pr):
1494
1598
  require(remote_url(receipt) == receipt["remote_url"], "remote changed during the delivery checks; retain resources")
1495
1599
 
1496
1600
 
1497
- def remove_delivered(receipt, path, *, warnings=None):
1601
+ def remove_delivered(receipt, path, *, warnings=None, anchor=None):
1498
1602
  warnings = [] if warnings is None else warnings
1499
1603
  sha = receipt["publish_sha"]
1500
1604
  branch_ref = "refs/heads/" + receipt["branch"]
1501
- cleanup_workspace(receipt, path, warnings=warnings)
1605
+ cleanup_workspace(receipt, path, warnings=warnings, anchor=anchor)
1502
1606
  checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
1503
1607
  require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
1504
1608
  # Check ownership/ref identity once more immediately before each deletion.
@@ -1518,9 +1622,9 @@ def remove_delivered(receipt, path, *, warnings=None):
1518
1622
  atomic_json(path, receipt)
1519
1623
 
1520
1624
 
1521
- def cleanup(receipt, path, pr, *, warnings=None):
1625
+ def cleanup(receipt, path, pr, *, warnings=None, anchor=None):
1522
1626
  verify_delivery(receipt, path, pr)
1523
- remove_delivered(receipt, path, warnings=warnings)
1627
+ remove_delivered(receipt, path, warnings=warnings, anchor=anchor)
1524
1628
 
1525
1629
 
1526
1630
  def resolve_receipt(path, *, operation_id=None):
@@ -1698,7 +1802,7 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
1698
1802
  cleanup_delivered(receipt, path, pr, warnings=warnings)
1699
1803
  else:
1700
1804
  try:
1701
- cleanup(receipt, path, pr, warnings=warnings)
1805
+ cleanup(receipt, path, pr, warnings=warnings, anchor=anchor)
1702
1806
  except (CompletionError, OSError) as error:
1703
1807
  if "workspace_cleanup" in receipt:
1704
1808
  # A retained cleanup names its latest refusal, which may guard less than the first.
@@ -3026,6 +3130,20 @@ ctypes.CDLL(None).pthread_exit(None)
3026
3130
  clean_scratch(receipt, self.receipt, True)
3027
3131
  self.assertEqual((scratch / "keep").read_text(), "retained")
3028
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
+
3029
3147
  def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
3030
3148
  from unittest.mock import patch
3031
3149
  target = "/task/scratch/name on inside\\040literal"
@@ -3638,21 +3756,321 @@ ctypes.CDLL(None).pthread_exit(None)
3638
3756
  self.assertFalse(self.task.exists())
3639
3757
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3640
3758
 
3641
- def test_unobservable_writers_settle_a_merged_delivery_and_retain_the_workspace(self):
3642
- from unittest.mock import patch
3759
+ def merged(self, *, released=True):
3643
3760
  self.allocate(); self.accept()
3644
- self.complete("--mode", "pr", "--writers-stopped")
3761
+ self.complete("--mode", "pr", *(["--writers-stopped"] if released else []))
3645
3762
  self.merge_pr()
3646
- args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
3647
- with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env):
3648
- 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.
3649
3796
  self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3650
- self.assertIn("writer observation unsupported", result["workspace_cleanup"]["reason"])
3651
- receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
3652
- self.assertEqual((receipt["delivery"], receipt.get("status")), ("COMPLETE", None))
3653
- self.assertTrue(self.task.exists())
3654
- 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)
3801
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
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)
3655
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)
3656
4074
 
3657
4075
  def test_standalone_cleanup_checks_writers_before_reading_records(self):
3658
4076
  from unittest.mock import patch
@@ -3880,6 +4298,23 @@ ctypes.CDLL(None).pthread_exit(None)
3880
4298
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3881
4299
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3882
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
+
3883
4318
  def test_reconcile_reports_scratch_cleanup_pending(self):
3884
4319
  scratch = Path(self.allocate()["scratch"])
3885
4320
  self.accept()
@@ -236,7 +236,7 @@ affected resources. Use `git worktree lock` to keep a tree.
236
236
  A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
237
237
  until it restarts; the helper names it when identifiable and retries cleanup on
238
238
  the next allocation or resume after the restart.
239
- On native Windows, locking and file durability are supported, but the helper cannot prove writer cessation and reports `writer observation unsupported on this platform; retain workspace`. Even `--writers-stopped` cannot authorize deletion without that observation: retain the workspace, its task refs and external receipt/custody, report delivery separately, and preserve the receipt-based resume command. A merged or on-base delivery therefore reports `CLEANUP_PENDING` (delivery `COMPLETE`) with `workspace_cleanup` naming the reason and resume. Do not kill unknown processes or force worktree removal to make completion pass.
239
+ On native Windows, a tree a program has a file open in, or runs in, is kept until that program lets go; one a program only watches may be removed. A managed tree instead waits for removal by hand, as its reason says; scratch that holds files is retained. Do not kill unknown processes or force worktree removal to make completion pass.
240
240
 
241
241
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
242
242
  `refs/devlyn/completed/<id>` recovery ref. Check evidence is copied there