devlyn-cli 4.3.13 → 4.3.15
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/bin/devlyn.js +318 -160
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +81 -19
- package/config/skills/_shared/task-completion.md +3 -1
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +81 -19
- package/native/claude/skills/_shared/task-completion.md +3 -1
- 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 +81 -19
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +3 -1
- package/package.json +1 -1
- package/release.json +14 -14
package/bin/skill-history.json
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"40f50f3c3c0d030d1efeb6955c0c6f32f518e737d8dd3bec6610ff02badebbdb",
|
|
53
53
|
"430d156eb8c62c7edba3d660050f90b267f928976c3a3688e95d54f00dc34747",
|
|
54
54
|
"450d578ef177a9e8f0b693940ad64b41c1d9719975ba6305f62a7c82ac3a892f",
|
|
55
|
+
"45ef27af9ec27089e2861c13b882521fc6bf073dc6b7a356b326b4a27c5e6e24",
|
|
55
56
|
"4685be6a94ad5587ce53785c14a60cccf9a852d9d23391dba0a5bcd0c64931cf",
|
|
56
57
|
"484db9dd2635de5f5acbcf692e2f329f33c0a483951e27bcd77bcbafbc052e19",
|
|
57
58
|
"4ecb004b24a8adfe41c7416c3fa24e62cf76baa3b276ae1ce5091caef77e57b8",
|
|
@@ -769,6 +770,8 @@
|
|
|
769
770
|
"4.3.11",
|
|
770
771
|
"4.3.12",
|
|
771
772
|
"4.3.13",
|
|
773
|
+
"4.3.14",
|
|
774
|
+
"4.3.15",
|
|
772
775
|
"4.3.2",
|
|
773
776
|
"4.3.3",
|
|
774
777
|
"4.3.4",
|
|
@@ -324,10 +324,12 @@ def allocate(args, *, exclude_receipts=(), warnings=None):
|
|
|
324
324
|
if local:
|
|
325
325
|
receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
|
|
326
326
|
else:
|
|
327
|
-
|
|
327
|
+
if args.repository is None:
|
|
328
|
+
receipt["repository"] = repository_from_url(gref(receipt, "remote", "get-url", "--all", args.remote))
|
|
328
329
|
policy(receipt, None)
|
|
329
330
|
receipt["remote_url"] = remote_url(receipt)
|
|
330
331
|
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
332
|
+
repo_policy(receipt, allocating=True)
|
|
331
333
|
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
332
334
|
require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
|
|
333
335
|
receipt["worktree"] = str(target)
|
|
@@ -541,9 +543,17 @@ def repository_info(repository):
|
|
|
541
543
|
return info
|
|
542
544
|
|
|
543
545
|
|
|
544
|
-
def repo_policy(receipt):
|
|
546
|
+
def repo_policy(receipt, *, allocating=False):
|
|
545
547
|
info = repository_info(receipt["repository"])
|
|
546
|
-
|
|
548
|
+
default = info["defaultBranchRef"]["name"]
|
|
549
|
+
if allocating:
|
|
550
|
+
if receipt["base"] is None:
|
|
551
|
+
gref(receipt, "check-ref-format", "refs/heads/" + default)
|
|
552
|
+
receipt["base"] = default
|
|
553
|
+
require(receipt["branch"] != receipt["base"], "cannot own a base/default branch")
|
|
554
|
+
require(default == receipt["base"] and receipt["branch"] != default,
|
|
555
|
+
f"base {receipt['base']!r} differs from default branch {default!r}; the helper delivers only to the default branch; use a local allocation with --local-base for other bases"
|
|
556
|
+
if allocating else "base/default branch changed; retain resources")
|
|
547
557
|
return info
|
|
548
558
|
|
|
549
559
|
|
|
@@ -1409,7 +1419,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1409
1419
|
from unittest.mock import patch
|
|
1410
1420
|
target = self.root / "absent" / "new"
|
|
1411
1421
|
exists = Path.exists
|
|
1412
|
-
with patch.
|
|
1422
|
+
with patch.dict(os.environ, self.env), \
|
|
1423
|
+
patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
|
|
1413
1424
|
patch.object(shutil, "disk_usage") as usage:
|
|
1414
1425
|
with self.assertRaisesRegex(CompletionError, f"no existing ancestor of {re.escape(str(target))} to measure free space"):
|
|
1415
1426
|
allocate(self.allocation_args("new", target))
|
|
@@ -1677,6 +1688,62 @@ class CompletionTests(unittest.TestCase):
|
|
|
1677
1688
|
self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
|
|
1678
1689
|
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1679
1690
|
|
|
1691
|
+
def test_allocate_derives_repository(self):
|
|
1692
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1693
|
+
"--branch", "task/fixture", "--base", "main", "--worktree", self.root / "linked")
|
|
1694
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1695
|
+
self.assertEqual(read_json(Path(result["receipt"]))["repository"], "test/project")
|
|
1696
|
+
|
|
1697
|
+
def test_allocate_derives_default_base(self):
|
|
1698
|
+
self.g("push", "origin", "main:refs/heads/release")
|
|
1699
|
+
self.configure(default_branch="release")
|
|
1700
|
+
for name, flags in (("explicit", ["--repository", "test/project"]), ("derived", [])):
|
|
1701
|
+
with self.subTest(name=name):
|
|
1702
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", name,
|
|
1703
|
+
"--branch", "task/" + name, "--worktree", self.root / name, *flags)
|
|
1704
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1705
|
+
receipt = read_json(Path(result["receipt"]))
|
|
1706
|
+
self.assertEqual((receipt["repository"], receipt["base"]), ("test/project", "release"))
|
|
1707
|
+
self.assertEqual(self.g("rev-parse", "HEAD", work=Path(result["worktree"])), receipt["baseline"])
|
|
1708
|
+
|
|
1709
|
+
def test_allocate_rejects_nondefault_base_before_resources(self):
|
|
1710
|
+
self.configure(default_branch="release")
|
|
1711
|
+
self.g("update-ref", "-d", "refs/remotes/origin/main")
|
|
1712
|
+
refs, worktrees = self.g("show-ref"), self.g("worktree", "list", "--porcelain")
|
|
1713
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1714
|
+
"--branch", "task/fixture", "--repository", "test/project", "--base", "main",
|
|
1715
|
+
"--worktree", self.root / "linked", success=False)
|
|
1716
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1717
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1718
|
+
for text in ("main", "release", "only to the default branch", "--local-base"):
|
|
1719
|
+
self.assertIn(text, result["reason"])
|
|
1720
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1721
|
+
self.assertEqual(self.g("worktree", "list", "--porcelain"), worktrees)
|
|
1722
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1723
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1724
|
+
|
|
1725
|
+
def test_allocate_rejects_derived_base_as_task_branch(self):
|
|
1726
|
+
self.configure(default_branch="release")
|
|
1727
|
+
refs = self.g("show-ref")
|
|
1728
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1729
|
+
"--branch", "release", "--worktree", self.root / "linked", success=False)
|
|
1730
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1731
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1732
|
+
self.assertIn("cannot own a base/default branch", result["reason"])
|
|
1733
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1734
|
+
self.assertEqual(self.g("branch", "--list", "release"), "")
|
|
1735
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1736
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1737
|
+
|
|
1738
|
+
def test_allocate_rejects_explicit_repository_mismatch(self):
|
|
1739
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1740
|
+
"--branch", "task/fixture", "--repository", "test/other", "--base", "main",
|
|
1741
|
+
"--worktree", self.root / "linked", success=False)
|
|
1742
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1743
|
+
self.assertEqual(result, {"status": "BLOCKED", "reason": "repository differs from remote"})
|
|
1744
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1745
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1746
|
+
|
|
1680
1747
|
def test_allocate_base_with_same_named_tag(self):
|
|
1681
1748
|
self.g("tag", "main")
|
|
1682
1749
|
result = self.allocate()
|
|
@@ -1687,6 +1754,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1687
1754
|
base = "main\u00a0"
|
|
1688
1755
|
self.g("switch", "-c", base)
|
|
1689
1756
|
self.g("push", "origin", "HEAD:refs/heads/"+base)
|
|
1757
|
+
self.configure(default_branch=base)
|
|
1690
1758
|
result = self.allocate(base=base)
|
|
1691
1759
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
1692
1760
|
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
|
|
@@ -1711,6 +1779,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1711
1779
|
def test_allocate_rejects_short_base_alias(self):
|
|
1712
1780
|
self.g("branch", "heads/main")
|
|
1713
1781
|
self.g("tag", "main")
|
|
1782
|
+
self.configure(default_branch="heads/main")
|
|
1714
1783
|
self.assert_base_identity_rejected("main", "heads/main")
|
|
1715
1784
|
|
|
1716
1785
|
def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
|
|
@@ -2295,7 +2364,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2295
2364
|
self.merge_pr()
|
|
2296
2365
|
receipt, task = self.receipt, self.task
|
|
2297
2366
|
self.configure(default_branch="changed")
|
|
2298
|
-
|
|
2367
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2368
|
+
result = self.allocate("next", base="changed")
|
|
2299
2369
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
|
|
2300
2370
|
self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
|
|
2301
2371
|
self.assertTrue(task.exists())
|
|
@@ -2398,12 +2468,13 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2398
2468
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2399
2469
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2400
2470
|
self.configure(default_branch="changed")
|
|
2471
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2401
2472
|
server = json.loads(self.data.read_text())
|
|
2402
|
-
result = self.allocate("retry")
|
|
2473
|
+
result = self.allocate("retry", base="changed")
|
|
2403
2474
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2404
2475
|
self.assertFalse(any(scratch.iterdir()))
|
|
2405
2476
|
self.assertEqual(json.loads(self.data.read_text()), server)
|
|
2406
|
-
self.assertEqual(self.allocate("later")["reconciled"], [])
|
|
2477
|
+
self.assertEqual(self.allocate("later", base="changed")["reconciled"], [])
|
|
2407
2478
|
|
|
2408
2479
|
def test_reconcile_retries_unattempted_scratch_cleanup(self):
|
|
2409
2480
|
scratch = Path(self.allocate()["scratch"])
|
|
@@ -3158,14 +3229,10 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3158
3229
|
self.assertFalse((self.root / "next").exists())
|
|
3159
3230
|
|
|
3160
3231
|
def test_local_allocation_needs_no_repository(self):
|
|
3161
|
-
"""
|
|
3162
|
-
local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
|
|
3163
|
-
LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
|
|
3232
|
+
"""A local allocation beside a non-GitHub origin needs no repository and completes without publication."""
|
|
3164
3233
|
args = ["allocate", "--repo", self.work, "--base", "main"]
|
|
3165
|
-
refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
|
|
3166
|
-
self.assertIn("--repository", refused["reason"])
|
|
3167
|
-
self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
|
|
3168
3234
|
self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
|
|
3235
|
+
(self.bin / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
3169
3236
|
result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
|
|
3170
3237
|
"--local-base", self.g("rev-parse", "HEAD"))
|
|
3171
3238
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
@@ -3175,13 +3242,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3175
3242
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
3176
3243
|
|
|
3177
3244
|
def test_local_allocation_needs_no_base(self):
|
|
3178
|
-
"""
|
|
3179
|
-
runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
|
|
3180
|
-
nothing created."""
|
|
3245
|
+
"""A local allocation with --local-base and no --base is ALLOCATED and completes LOCAL_ONLY."""
|
|
3181
3246
|
args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
|
|
3182
|
-
refused, _ = self.cli(*args, "--repository", "test/project", success=False)
|
|
3183
|
-
self.assertIn("--base", refused["reason"])
|
|
3184
|
-
self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
|
|
3185
3247
|
result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
|
|
3186
3248
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
3187
3249
|
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
@@ -55,9 +55,11 @@ not write to the shared checkout.
|
|
|
55
55
|
python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
|
|
56
56
|
--task '<task identity>' --branch '<absent task branch>' \
|
|
57
57
|
--worktree '<repo parent>/.devlyn-worktrees/<repo name>/<task directory>' \
|
|
58
|
-
--
|
|
58
|
+
--remote origin
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
The repository comes from the remote's GitHub URL and the base from the repository's default branch; passing either flag checks it instead.
|
|
62
|
+
A non-default base is refused at allocation.
|
|
61
63
|
Every delivery allocation owns a linked worktree; `--worktree` is required and
|
|
62
64
|
must name an absent path.
|
|
63
65
|
Cleanup removes empty parents through the exact per-repo root shown above or its legacy `.devlyn.noindex`/`.devlyn` root, keeping the shared `.devlyn-worktrees` container. Allocation also sweeps empty legacy roots for this repo.
|
|
@@ -324,10 +324,12 @@ def allocate(args, *, exclude_receipts=(), warnings=None):
|
|
|
324
324
|
if local:
|
|
325
325
|
receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
|
|
326
326
|
else:
|
|
327
|
-
|
|
327
|
+
if args.repository is None:
|
|
328
|
+
receipt["repository"] = repository_from_url(gref(receipt, "remote", "get-url", "--all", args.remote))
|
|
328
329
|
policy(receipt, None)
|
|
329
330
|
receipt["remote_url"] = remote_url(receipt)
|
|
330
331
|
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
332
|
+
repo_policy(receipt, allocating=True)
|
|
331
333
|
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
332
334
|
require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
|
|
333
335
|
receipt["worktree"] = str(target)
|
|
@@ -541,9 +543,17 @@ def repository_info(repository):
|
|
|
541
543
|
return info
|
|
542
544
|
|
|
543
545
|
|
|
544
|
-
def repo_policy(receipt):
|
|
546
|
+
def repo_policy(receipt, *, allocating=False):
|
|
545
547
|
info = repository_info(receipt["repository"])
|
|
546
|
-
|
|
548
|
+
default = info["defaultBranchRef"]["name"]
|
|
549
|
+
if allocating:
|
|
550
|
+
if receipt["base"] is None:
|
|
551
|
+
gref(receipt, "check-ref-format", "refs/heads/" + default)
|
|
552
|
+
receipt["base"] = default
|
|
553
|
+
require(receipt["branch"] != receipt["base"], "cannot own a base/default branch")
|
|
554
|
+
require(default == receipt["base"] and receipt["branch"] != default,
|
|
555
|
+
f"base {receipt['base']!r} differs from default branch {default!r}; the helper delivers only to the default branch; use a local allocation with --local-base for other bases"
|
|
556
|
+
if allocating else "base/default branch changed; retain resources")
|
|
547
557
|
return info
|
|
548
558
|
|
|
549
559
|
|
|
@@ -1409,7 +1419,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1409
1419
|
from unittest.mock import patch
|
|
1410
1420
|
target = self.root / "absent" / "new"
|
|
1411
1421
|
exists = Path.exists
|
|
1412
|
-
with patch.
|
|
1422
|
+
with patch.dict(os.environ, self.env), \
|
|
1423
|
+
patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
|
|
1413
1424
|
patch.object(shutil, "disk_usage") as usage:
|
|
1414
1425
|
with self.assertRaisesRegex(CompletionError, f"no existing ancestor of {re.escape(str(target))} to measure free space"):
|
|
1415
1426
|
allocate(self.allocation_args("new", target))
|
|
@@ -1677,6 +1688,62 @@ class CompletionTests(unittest.TestCase):
|
|
|
1677
1688
|
self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
|
|
1678
1689
|
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1679
1690
|
|
|
1691
|
+
def test_allocate_derives_repository(self):
|
|
1692
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1693
|
+
"--branch", "task/fixture", "--base", "main", "--worktree", self.root / "linked")
|
|
1694
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1695
|
+
self.assertEqual(read_json(Path(result["receipt"]))["repository"], "test/project")
|
|
1696
|
+
|
|
1697
|
+
def test_allocate_derives_default_base(self):
|
|
1698
|
+
self.g("push", "origin", "main:refs/heads/release")
|
|
1699
|
+
self.configure(default_branch="release")
|
|
1700
|
+
for name, flags in (("explicit", ["--repository", "test/project"]), ("derived", [])):
|
|
1701
|
+
with self.subTest(name=name):
|
|
1702
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", name,
|
|
1703
|
+
"--branch", "task/" + name, "--worktree", self.root / name, *flags)
|
|
1704
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1705
|
+
receipt = read_json(Path(result["receipt"]))
|
|
1706
|
+
self.assertEqual((receipt["repository"], receipt["base"]), ("test/project", "release"))
|
|
1707
|
+
self.assertEqual(self.g("rev-parse", "HEAD", work=Path(result["worktree"])), receipt["baseline"])
|
|
1708
|
+
|
|
1709
|
+
def test_allocate_rejects_nondefault_base_before_resources(self):
|
|
1710
|
+
self.configure(default_branch="release")
|
|
1711
|
+
self.g("update-ref", "-d", "refs/remotes/origin/main")
|
|
1712
|
+
refs, worktrees = self.g("show-ref"), self.g("worktree", "list", "--porcelain")
|
|
1713
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1714
|
+
"--branch", "task/fixture", "--repository", "test/project", "--base", "main",
|
|
1715
|
+
"--worktree", self.root / "linked", success=False)
|
|
1716
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1717
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1718
|
+
for text in ("main", "release", "only to the default branch", "--local-base"):
|
|
1719
|
+
self.assertIn(text, result["reason"])
|
|
1720
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1721
|
+
self.assertEqual(self.g("worktree", "list", "--porcelain"), worktrees)
|
|
1722
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1723
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1724
|
+
|
|
1725
|
+
def test_allocate_rejects_derived_base_as_task_branch(self):
|
|
1726
|
+
self.configure(default_branch="release")
|
|
1727
|
+
refs = self.g("show-ref")
|
|
1728
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1729
|
+
"--branch", "release", "--worktree", self.root / "linked", success=False)
|
|
1730
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1731
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1732
|
+
self.assertIn("cannot own a base/default branch", result["reason"])
|
|
1733
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1734
|
+
self.assertEqual(self.g("branch", "--list", "release"), "")
|
|
1735
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1736
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1737
|
+
|
|
1738
|
+
def test_allocate_rejects_explicit_repository_mismatch(self):
|
|
1739
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1740
|
+
"--branch", "task/fixture", "--repository", "test/other", "--base", "main",
|
|
1741
|
+
"--worktree", self.root / "linked", success=False)
|
|
1742
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1743
|
+
self.assertEqual(result, {"status": "BLOCKED", "reason": "repository differs from remote"})
|
|
1744
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1745
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1746
|
+
|
|
1680
1747
|
def test_allocate_base_with_same_named_tag(self):
|
|
1681
1748
|
self.g("tag", "main")
|
|
1682
1749
|
result = self.allocate()
|
|
@@ -1687,6 +1754,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1687
1754
|
base = "main\u00a0"
|
|
1688
1755
|
self.g("switch", "-c", base)
|
|
1689
1756
|
self.g("push", "origin", "HEAD:refs/heads/"+base)
|
|
1757
|
+
self.configure(default_branch=base)
|
|
1690
1758
|
result = self.allocate(base=base)
|
|
1691
1759
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
1692
1760
|
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
|
|
@@ -1711,6 +1779,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1711
1779
|
def test_allocate_rejects_short_base_alias(self):
|
|
1712
1780
|
self.g("branch", "heads/main")
|
|
1713
1781
|
self.g("tag", "main")
|
|
1782
|
+
self.configure(default_branch="heads/main")
|
|
1714
1783
|
self.assert_base_identity_rejected("main", "heads/main")
|
|
1715
1784
|
|
|
1716
1785
|
def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
|
|
@@ -2295,7 +2364,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2295
2364
|
self.merge_pr()
|
|
2296
2365
|
receipt, task = self.receipt, self.task
|
|
2297
2366
|
self.configure(default_branch="changed")
|
|
2298
|
-
|
|
2367
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2368
|
+
result = self.allocate("next", base="changed")
|
|
2299
2369
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
|
|
2300
2370
|
self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
|
|
2301
2371
|
self.assertTrue(task.exists())
|
|
@@ -2398,12 +2468,13 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2398
2468
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2399
2469
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2400
2470
|
self.configure(default_branch="changed")
|
|
2471
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2401
2472
|
server = json.loads(self.data.read_text())
|
|
2402
|
-
result = self.allocate("retry")
|
|
2473
|
+
result = self.allocate("retry", base="changed")
|
|
2403
2474
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2404
2475
|
self.assertFalse(any(scratch.iterdir()))
|
|
2405
2476
|
self.assertEqual(json.loads(self.data.read_text()), server)
|
|
2406
|
-
self.assertEqual(self.allocate("later")["reconciled"], [])
|
|
2477
|
+
self.assertEqual(self.allocate("later", base="changed")["reconciled"], [])
|
|
2407
2478
|
|
|
2408
2479
|
def test_reconcile_retries_unattempted_scratch_cleanup(self):
|
|
2409
2480
|
scratch = Path(self.allocate()["scratch"])
|
|
@@ -3158,14 +3229,10 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3158
3229
|
self.assertFalse((self.root / "next").exists())
|
|
3159
3230
|
|
|
3160
3231
|
def test_local_allocation_needs_no_repository(self):
|
|
3161
|
-
"""
|
|
3162
|
-
local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
|
|
3163
|
-
LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
|
|
3232
|
+
"""A local allocation beside a non-GitHub origin needs no repository and completes without publication."""
|
|
3164
3233
|
args = ["allocate", "--repo", self.work, "--base", "main"]
|
|
3165
|
-
refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
|
|
3166
|
-
self.assertIn("--repository", refused["reason"])
|
|
3167
|
-
self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
|
|
3168
3234
|
self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
|
|
3235
|
+
(self.bin / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
3169
3236
|
result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
|
|
3170
3237
|
"--local-base", self.g("rev-parse", "HEAD"))
|
|
3171
3238
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
@@ -3175,13 +3242,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3175
3242
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
3176
3243
|
|
|
3177
3244
|
def test_local_allocation_needs_no_base(self):
|
|
3178
|
-
"""
|
|
3179
|
-
runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
|
|
3180
|
-
nothing created."""
|
|
3245
|
+
"""A local allocation with --local-base and no --base is ALLOCATED and completes LOCAL_ONLY."""
|
|
3181
3246
|
args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
|
|
3182
|
-
refused, _ = self.cli(*args, "--repository", "test/project", success=False)
|
|
3183
|
-
self.assertIn("--base", refused["reason"])
|
|
3184
|
-
self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
|
|
3185
3247
|
result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
|
|
3186
3248
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
3187
3249
|
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
@@ -55,9 +55,11 @@ not write to the shared checkout.
|
|
|
55
55
|
python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
|
|
56
56
|
--task '<task identity>' --branch '<absent task branch>' \
|
|
57
57
|
--worktree '<repo parent>/.devlyn-worktrees/<repo name>/<task directory>' \
|
|
58
|
-
--
|
|
58
|
+
--remote origin
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
The repository comes from the remote's GitHub URL and the base from the repository's default branch; passing either flag checks it instead.
|
|
62
|
+
A non-default base is refused at allocation.
|
|
61
63
|
Every delivery allocation owns a linked worktree; `--worktree` is required and
|
|
62
64
|
must name an absent path.
|
|
63
65
|
Cleanup removes empty parents through the exact per-repo root shown above or its legacy `.devlyn.noindex`/`.devlyn` root, keeping the shared `.devlyn-worktrees` container. Allocation also sweeps empty legacy roots for this repo.
|
|
@@ -324,10 +324,12 @@ def allocate(args, *, exclude_receipts=(), warnings=None):
|
|
|
324
324
|
if local:
|
|
325
325
|
receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
|
|
326
326
|
else:
|
|
327
|
-
|
|
327
|
+
if args.repository is None:
|
|
328
|
+
receipt["repository"] = repository_from_url(gref(receipt, "remote", "get-url", "--all", args.remote))
|
|
328
329
|
policy(receipt, None)
|
|
329
330
|
receipt["remote_url"] = remote_url(receipt)
|
|
330
331
|
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
332
|
+
repo_policy(receipt, allocating=True)
|
|
331
333
|
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
332
334
|
require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
|
|
333
335
|
receipt["worktree"] = str(target)
|
|
@@ -541,9 +543,17 @@ def repository_info(repository):
|
|
|
541
543
|
return info
|
|
542
544
|
|
|
543
545
|
|
|
544
|
-
def repo_policy(receipt):
|
|
546
|
+
def repo_policy(receipt, *, allocating=False):
|
|
545
547
|
info = repository_info(receipt["repository"])
|
|
546
|
-
|
|
548
|
+
default = info["defaultBranchRef"]["name"]
|
|
549
|
+
if allocating:
|
|
550
|
+
if receipt["base"] is None:
|
|
551
|
+
gref(receipt, "check-ref-format", "refs/heads/" + default)
|
|
552
|
+
receipt["base"] = default
|
|
553
|
+
require(receipt["branch"] != receipt["base"], "cannot own a base/default branch")
|
|
554
|
+
require(default == receipt["base"] and receipt["branch"] != default,
|
|
555
|
+
f"base {receipt['base']!r} differs from default branch {default!r}; the helper delivers only to the default branch; use a local allocation with --local-base for other bases"
|
|
556
|
+
if allocating else "base/default branch changed; retain resources")
|
|
547
557
|
return info
|
|
548
558
|
|
|
549
559
|
|
|
@@ -1409,7 +1419,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1409
1419
|
from unittest.mock import patch
|
|
1410
1420
|
target = self.root / "absent" / "new"
|
|
1411
1421
|
exists = Path.exists
|
|
1412
|
-
with patch.
|
|
1422
|
+
with patch.dict(os.environ, self.env), \
|
|
1423
|
+
patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
|
|
1413
1424
|
patch.object(shutil, "disk_usage") as usage:
|
|
1414
1425
|
with self.assertRaisesRegex(CompletionError, f"no existing ancestor of {re.escape(str(target))} to measure free space"):
|
|
1415
1426
|
allocate(self.allocation_args("new", target))
|
|
@@ -1677,6 +1688,62 @@ class CompletionTests(unittest.TestCase):
|
|
|
1677
1688
|
self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
|
|
1678
1689
|
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1679
1690
|
|
|
1691
|
+
def test_allocate_derives_repository(self):
|
|
1692
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1693
|
+
"--branch", "task/fixture", "--base", "main", "--worktree", self.root / "linked")
|
|
1694
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1695
|
+
self.assertEqual(read_json(Path(result["receipt"]))["repository"], "test/project")
|
|
1696
|
+
|
|
1697
|
+
def test_allocate_derives_default_base(self):
|
|
1698
|
+
self.g("push", "origin", "main:refs/heads/release")
|
|
1699
|
+
self.configure(default_branch="release")
|
|
1700
|
+
for name, flags in (("explicit", ["--repository", "test/project"]), ("derived", [])):
|
|
1701
|
+
with self.subTest(name=name):
|
|
1702
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", name,
|
|
1703
|
+
"--branch", "task/" + name, "--worktree", self.root / name, *flags)
|
|
1704
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1705
|
+
receipt = read_json(Path(result["receipt"]))
|
|
1706
|
+
self.assertEqual((receipt["repository"], receipt["base"]), ("test/project", "release"))
|
|
1707
|
+
self.assertEqual(self.g("rev-parse", "HEAD", work=Path(result["worktree"])), receipt["baseline"])
|
|
1708
|
+
|
|
1709
|
+
def test_allocate_rejects_nondefault_base_before_resources(self):
|
|
1710
|
+
self.configure(default_branch="release")
|
|
1711
|
+
self.g("update-ref", "-d", "refs/remotes/origin/main")
|
|
1712
|
+
refs, worktrees = self.g("show-ref"), self.g("worktree", "list", "--porcelain")
|
|
1713
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1714
|
+
"--branch", "task/fixture", "--repository", "test/project", "--base", "main",
|
|
1715
|
+
"--worktree", self.root / "linked", success=False)
|
|
1716
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1717
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1718
|
+
for text in ("main", "release", "only to the default branch", "--local-base"):
|
|
1719
|
+
self.assertIn(text, result["reason"])
|
|
1720
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1721
|
+
self.assertEqual(self.g("worktree", "list", "--porcelain"), worktrees)
|
|
1722
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1723
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1724
|
+
|
|
1725
|
+
def test_allocate_rejects_derived_base_as_task_branch(self):
|
|
1726
|
+
self.configure(default_branch="release")
|
|
1727
|
+
refs = self.g("show-ref")
|
|
1728
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1729
|
+
"--branch", "release", "--worktree", self.root / "linked", success=False)
|
|
1730
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1731
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1732
|
+
self.assertIn("cannot own a base/default branch", result["reason"])
|
|
1733
|
+
self.assertEqual(self.g("show-ref"), refs)
|
|
1734
|
+
self.assertEqual(self.g("branch", "--list", "release"), "")
|
|
1735
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1736
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1737
|
+
|
|
1738
|
+
def test_allocate_rejects_explicit_repository_mismatch(self):
|
|
1739
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1740
|
+
"--branch", "task/fixture", "--repository", "test/other", "--base", "main",
|
|
1741
|
+
"--worktree", self.root / "linked", success=False)
|
|
1742
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1743
|
+
self.assertEqual(result, {"status": "BLOCKED", "reason": "repository differs from remote"})
|
|
1744
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1745
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1746
|
+
|
|
1680
1747
|
def test_allocate_base_with_same_named_tag(self):
|
|
1681
1748
|
self.g("tag", "main")
|
|
1682
1749
|
result = self.allocate()
|
|
@@ -1687,6 +1754,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1687
1754
|
base = "main\u00a0"
|
|
1688
1755
|
self.g("switch", "-c", base)
|
|
1689
1756
|
self.g("push", "origin", "HEAD:refs/heads/"+base)
|
|
1757
|
+
self.configure(default_branch=base)
|
|
1690
1758
|
result = self.allocate(base=base)
|
|
1691
1759
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
1692
1760
|
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
|
|
@@ -1711,6 +1779,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1711
1779
|
def test_allocate_rejects_short_base_alias(self):
|
|
1712
1780
|
self.g("branch", "heads/main")
|
|
1713
1781
|
self.g("tag", "main")
|
|
1782
|
+
self.configure(default_branch="heads/main")
|
|
1714
1783
|
self.assert_base_identity_rejected("main", "heads/main")
|
|
1715
1784
|
|
|
1716
1785
|
def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
|
|
@@ -2295,7 +2364,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2295
2364
|
self.merge_pr()
|
|
2296
2365
|
receipt, task = self.receipt, self.task
|
|
2297
2366
|
self.configure(default_branch="changed")
|
|
2298
|
-
|
|
2367
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2368
|
+
result = self.allocate("next", base="changed")
|
|
2299
2369
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
|
|
2300
2370
|
self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
|
|
2301
2371
|
self.assertTrue(task.exists())
|
|
@@ -2398,12 +2468,13 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2398
2468
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2399
2469
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2400
2470
|
self.configure(default_branch="changed")
|
|
2471
|
+
self.g("push", "origin", "main:refs/heads/changed")
|
|
2401
2472
|
server = json.loads(self.data.read_text())
|
|
2402
|
-
result = self.allocate("retry")
|
|
2473
|
+
result = self.allocate("retry", base="changed")
|
|
2403
2474
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2404
2475
|
self.assertFalse(any(scratch.iterdir()))
|
|
2405
2476
|
self.assertEqual(json.loads(self.data.read_text()), server)
|
|
2406
|
-
self.assertEqual(self.allocate("later")["reconciled"], [])
|
|
2477
|
+
self.assertEqual(self.allocate("later", base="changed")["reconciled"], [])
|
|
2407
2478
|
|
|
2408
2479
|
def test_reconcile_retries_unattempted_scratch_cleanup(self):
|
|
2409
2480
|
scratch = Path(self.allocate()["scratch"])
|
|
@@ -3158,14 +3229,10 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3158
3229
|
self.assertFalse((self.root / "next").exists())
|
|
3159
3230
|
|
|
3160
3231
|
def test_local_allocation_needs_no_repository(self):
|
|
3161
|
-
"""
|
|
3162
|
-
local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
|
|
3163
|
-
LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
|
|
3232
|
+
"""A local allocation beside a non-GitHub origin needs no repository and completes without publication."""
|
|
3164
3233
|
args = ["allocate", "--repo", self.work, "--base", "main"]
|
|
3165
|
-
refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
|
|
3166
|
-
self.assertIn("--repository", refused["reason"])
|
|
3167
|
-
self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
|
|
3168
3234
|
self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
|
|
3235
|
+
(self.bin / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
3169
3236
|
result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
|
|
3170
3237
|
"--local-base", self.g("rev-parse", "HEAD"))
|
|
3171
3238
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
@@ -3175,13 +3242,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3175
3242
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
3176
3243
|
|
|
3177
3244
|
def test_local_allocation_needs_no_base(self):
|
|
3178
|
-
"""
|
|
3179
|
-
runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
|
|
3180
|
-
nothing created."""
|
|
3245
|
+
"""A local allocation with --local-base and no --base is ALLOCATED and completes LOCAL_ONLY."""
|
|
3181
3246
|
args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
|
|
3182
|
-
refused, _ = self.cli(*args, "--repository", "test/project", success=False)
|
|
3183
|
-
self.assertIn("--base", refused["reason"])
|
|
3184
|
-
self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
|
|
3185
3247
|
result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
|
|
3186
3248
|
self.assertEqual(result["status"], "ALLOCATED")
|
|
3187
3249
|
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|