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.
- package/README.md +4 -2
- package/bin/devlyn.js +109 -22
- package/bin/harness-activation.js +4 -1
- package/bin/harness-status.js +15 -8
- package/bin/skill-history.json +10 -0
- package/config/skills/_shared/bring-in.py +10 -0
- package/config/skills/_shared/task-complete.py +787 -87
- package/config/skills/_shared/task-completion.md +6 -6
- package/config/skills/_shared/warm-build-dirs.py +13 -0
- package/config/skills/devlyn-ideate/SKILL.md +3 -2
- package/config/skills/devlyn-ideate/references/loop.md +2 -2
- package/config/skills/devlyn-ideate/scripts/acceptance.py +11 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +685 -10
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/bring-in.py +10 -0
- package/native/claude/skills/_shared/task-complete.py +787 -87
- package/native/claude/skills/_shared/task-completion.md +6 -6
- package/native/claude/skills/_shared/warm-build-dirs.py +13 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +3 -2
- package/native/claude/skills/devlyn-ideate/references/loop.md +2 -2
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +11 -0
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +685 -10
- 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/bring-in.py +10 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +787 -87
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +6 -6
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +13 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +3 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +11 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +685 -10
- package/package.json +1 -1
- 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
|
-
|
|
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))
|
|
@@ -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
|
|
1484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1752
|
+
remove_delivered(receipt, path, warnings=warnings)
|
|
1635
1753
|
except (CompletionError, OSError) as error:
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1832
|
-
|
|
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: {},
|
|
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: {},
|
|
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"
|
|
3244
|
-
self.assertEqual(result["status"], "
|
|
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)),
|
|
3429
|
-
|
|
3430
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
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(
|
|
3459
|
-
|
|
3460
|
-
self.assertEqual((receipt["delivery"],
|
|
3461
|
-
self.
|
|
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(
|
|
3745
|
-
self.
|
|
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(
|
|
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(
|
|
3821
|
-
self.
|
|
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
|
-
|
|
3849
|
-
|
|
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
|
-
|
|
3872
|
-
|
|
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,
|
|
3881
|
-
self.
|
|
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
|
-
|
|
3934
|
-
|
|
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
|
-
|
|
4010
|
-
self.
|
|
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
|
-
|
|
4019
|
-
self.
|
|
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
|
|
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 ("
|
|
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"], ["
|
|
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
|
|
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"], "
|
|
4073
|
-
self.
|
|
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"], "
|
|
4158
|
-
self.
|
|
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
|
-
|
|
4218
|
-
self.
|
|
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)])
|