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.
- package/README.md +1 -1
- package/bin/devlyn.js +76 -14
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +472 -37
- package/config/skills/_shared/task-completion.md +1 -1
- package/config/skills/devlyn-ideate/SKILL.md +3 -2
- package/config/skills/devlyn-ideate/references/loop.md +1 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +668 -9
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +472 -37
- package/native/claude/skills/_shared/task-completion.md +1 -1
- package/native/claude/skills/devlyn-ideate/SKILL.md +3 -2
- package/native/claude/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +668 -9
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +472 -37
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +3 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +668 -9
- package/package.json +1 -1
- package/release.json +24 -24
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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(
|
|
3651
|
-
|
|
3652
|
-
self.assertEqual((receipt["delivery"],
|
|
3653
|
-
self.
|
|
3654
|
-
self.
|
|
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,
|
|
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
|