devlyn-cli 4.2.23 → 4.2.25
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/skill-history.json +6 -0
- package/config/skills/_shared/task-complete.py +235 -0
- package/config/skills/_shared/task-completion.md +16 -5
- package/config/skills/_shared/warm-build-dirs.py +899 -0
- package/config/skills/devlyn-ideate/references/loop.md +3 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +0 -15
- package/package.json +1 -1
package/bin/skill-history.json
CHANGED
|
@@ -23,15 +23,18 @@
|
|
|
23
23
|
"21723943c52573124ce3c74e9090a8ae7d7c9ecdeb78fdfa52b89c45ff1b3374",
|
|
24
24
|
"247904cad29c1c3ed810f848e66f982e8765e5c627ef3a18a876fd95eed9c29c",
|
|
25
25
|
"24f73bd06d359296088e43ce2e7bf5178e3f69752b559639585563ec35fc33f5",
|
|
26
|
+
"2692865ee864aebedb2b385384b2a8220bc780bd92310bc1cf2f56927de1e0b8",
|
|
26
27
|
"27d4da7863ed4652187f6a101da0e56b1d3d18e4565c720d5f8150ad0a193402",
|
|
27
28
|
"2a4361bca0a9fadc4a8e5e4578c383171cd64c65296fe882990502250df44d53",
|
|
28
29
|
"2bcac1ab5995e474c7dd46705413982876384d8b4da704126a66c10770a1a4d2",
|
|
29
30
|
"2bcae17ca31adaf306d1e5d21ad0c211bbd6f57605f68168f656e078dbf524e2",
|
|
30
31
|
"326a71c447fe4d98f3460eda3fc541920b7cc2205b8288743e8622d90215a49d",
|
|
31
32
|
"34282740d421a65fc75dc95edf260aa0d0fc6b90472f7b4641cb80110df0a359",
|
|
33
|
+
"3429daf66b5130b3ff6e48f3015afbf7a8d7fd84d09aace462aadb3099aa3f09",
|
|
32
34
|
"353b483942a62b79d1c5bf31e30060ef7ed9a67ae2d8b27450a53acb98e4e23c",
|
|
33
35
|
"3554398c2be144938e4837d9b60c0976f685cfc2c0dd0451cda714fd053f3c7d",
|
|
34
36
|
"35c7e6c069c847ff147f2bf4f3b67a296652c122aa91a3966457a00dd0597b78",
|
|
37
|
+
"364aa97ec4065d8b78188f40281dfa575120a8fb91089120363e532566ef0dcb",
|
|
35
38
|
"37acccbdc76aee15311b5f47fd26c50b1652dbe7cbcbacfc37774841ab464028",
|
|
36
39
|
"393ad728eb0906ae61110fde3820c4fce015d8f94643a2877372b74a6b6eeb02",
|
|
37
40
|
"3b6193eabc83d815804533010ca7bff1f240286e7898380a5fc2adac71dcb513",
|
|
@@ -99,6 +102,7 @@
|
|
|
99
102
|
"9162fc2fed6f4a611d299485be3a397080beb3304865ce59b95576ba7d00650e",
|
|
100
103
|
"94a14a1b87df1d7297d12949a24e47a0d6c16a8fe30495ee759fcabb2e1aa398",
|
|
101
104
|
"978e4210e49b42827c98cf6e40c868c87dc6e3533a2b779ca2fa05abfc16ad0f",
|
|
105
|
+
"97e7ad89c74c609b15dfbf6f9f7ac61df8b50e97e4bc318f72922e058ecffba4",
|
|
102
106
|
"9815b6e6963e068872a6dbeae24068143bf92042bb035f67bafd1a224d65bcb0",
|
|
103
107
|
"998343c148618b937556b58b83749c05d5aa3283280d751c118da6a542fb87f2",
|
|
104
108
|
"9dd3ec225818efe0ad3726192db94ff129154e995617a9d3c150fc58ad0f9f2c",
|
|
@@ -338,9 +342,11 @@
|
|
|
338
342
|
"9d62f726c5ac7ed8b3989c6ab3705b5c9e4b44bb70eeece0d43bd76142b9b75b",
|
|
339
343
|
"a12b24d2a70917b5d4e698af5478b6cce4d09ddf021f63ab7129402867433a72",
|
|
340
344
|
"a3b852c30898b572021b32721638f67d9f44cae98653da3362456a7b88208de7",
|
|
345
|
+
"a3c1de9718724e26c095af6f35baf3b7af1afc25ff33657ff3e8db066c692408",
|
|
341
346
|
"a7d7d65fd5f672cb404e6069fdc42ad7077deaa80ed8500cfa8b382dab2e3e29",
|
|
342
347
|
"aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
|
|
343
348
|
"b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
|
|
349
|
+
"bdd0751eea04532c050334fda3bc5c61c6b429e485d97e0b8ebb4fae154da2c7",
|
|
344
350
|
"c1b8e3ebf38e2be8972aa003e9614a782cb4f195c69d20b95a475ebf9aaaab9e",
|
|
345
351
|
"c8e375aab574148542f392b093cbbf3f2e8dc623826b45e35df0a1a2c3862226",
|
|
346
352
|
"ce282a55328c2d071cacc377670fbebea4da4b9a7fee81b25293fb26763f17c9",
|
|
@@ -186,6 +186,37 @@ def policy(receipt, override):
|
|
|
186
186
|
return override or value or "auto"
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
def ignore_task_records(work):
|
|
190
|
+
"""Keep task records ignored without changing existing files or shared Git configuration."""
|
|
191
|
+
ignorecase = command(["git", "-C", str(work), "config", "--type=bool", "--get", "core.ignorecase"], ok=(0, 1)) == "true"
|
|
192
|
+
pathspec = ":(icase)" if ignorecase else ""
|
|
193
|
+
|
|
194
|
+
def ignored():
|
|
195
|
+
all_ignored = True
|
|
196
|
+
for probe in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
|
|
197
|
+
result = subprocess.run(["git", "-C", str(work), "--no-literal-pathspecs", "check-ignore", "-q", "--", probe], capture_output=True, text=True, encoding="utf-8")
|
|
198
|
+
require(result.returncode in (0, 1), f"git check-ignore {probe}: {result.stderr.strip()}")
|
|
199
|
+
if result.returncode == 1:
|
|
200
|
+
all_ignored = False
|
|
201
|
+
return all_ignored
|
|
202
|
+
|
|
203
|
+
directory = work / ".devlyn"
|
|
204
|
+
require(not directory.is_symlink() and (not directory.exists() or directory.is_dir())
|
|
205
|
+
and all((path.casefold() if ignorecase else path) != ".devlyn"
|
|
206
|
+
for path in git(work, "--no-literal-pathspecs", "ls-files", "--", pathspec + ".devlyn").splitlines()),
|
|
207
|
+
f"{directory} is not a real directory; move it out of .devlyn in the starting commit before allocating")
|
|
208
|
+
if ignored():
|
|
209
|
+
return
|
|
210
|
+
rule = directory / ".gitignore"
|
|
211
|
+
require(not os.path.lexists(rule) and not git(work, "--no-literal-pathspecs", "ls-files", "--", pathspec + ".devlyn/.gitignore"),
|
|
212
|
+
f"{rule} already exists or is tracked; make it ignore acceptance.json and loop/evidence in the starting commit before allocating")
|
|
213
|
+
directory.mkdir(exist_ok=True)
|
|
214
|
+
with rule.open("x", encoding="utf-8") as stream:
|
|
215
|
+
stream.write("*\n")
|
|
216
|
+
require(ignored() and not git(work, "--no-literal-pathspecs", "status", "--porcelain", "--untracked-files=all", "--", ".devlyn"),
|
|
217
|
+
f"{directory} records are not ignored or .devlyn is dirty; repair the starting commit's .devlyn paths and ignore rules before allocating")
|
|
218
|
+
|
|
219
|
+
|
|
189
220
|
def allocate(args, *, exclude_receipts=()):
|
|
190
221
|
"""Reconcile older tasks, except receipts the queue has identified as invalid."""
|
|
191
222
|
work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
|
|
@@ -225,11 +256,20 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
225
256
|
# stays visibly incomplete; a later call may not adopt whatever now exists.
|
|
226
257
|
atomic_json(path, receipt)
|
|
227
258
|
gref(receipt, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
|
|
259
|
+
try:
|
|
260
|
+
ignore_task_records(target)
|
|
261
|
+
except CompletionError as exc:
|
|
262
|
+
raise CompletionError(f"{exc}; remove worktree {target} and branch {args.branch}, delete receipt directory {directory}, then allocate again") from exc
|
|
228
263
|
receipt["worktree_gitdir"] = git(target, "rev-parse", "--absolute-git-dir")
|
|
229
264
|
receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
|
|
230
265
|
receipt["allocation"] = "owned"
|
|
231
266
|
atomic_json(path, receipt)
|
|
267
|
+
# Owned first: an interrupted warm-up leaves a usable allocation whose build starts cold.
|
|
268
|
+
with locked_receipt(path) as owned:
|
|
269
|
+
owned["build_dirs"] = shared("warm-build-dirs")["warm"](target, work, scratch)
|
|
270
|
+
atomic_json(path, owned)
|
|
232
271
|
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
|
|
272
|
+
"build_dirs": owned["build_dirs"],
|
|
233
273
|
"reconciled": [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)}
|
|
234
274
|
|
|
235
275
|
|
|
@@ -1106,6 +1146,201 @@ class CompletionTests(unittest.TestCase):
|
|
|
1106
1146
|
self.g("status", "--porcelain", "--untracked-files=all", work=work), (work / "product").read_bytes(),
|
|
1107
1147
|
(Path(self.g("rev-parse", "--absolute-git-dir", work=work)) / "index").read_bytes())
|
|
1108
1148
|
|
|
1149
|
+
def test_unignored_base_delivers_only_product_and_preserves_records(self):
|
|
1150
|
+
# Prediction: with the ignore rule only in the anchor's dirty .gitignore, remote, --start, --local-base
|
|
1151
|
+
# and --from-receipt allocations complete in their requested modes; only product is delivered, evidence
|
|
1152
|
+
# survives custody/cleanup, and the anchor's .gitignore, info/exclude and HEAD remain byte-identical.
|
|
1153
|
+
ignore = self.work / ".gitignore"
|
|
1154
|
+
ignore.write_text("ignored/\nnode_modules/\n.env\n", encoding="utf-8")
|
|
1155
|
+
self.g("commit", "-am", "base without .devlyn ignore")
|
|
1156
|
+
self.g("push", "origin", "main")
|
|
1157
|
+
base = self.g("rev-parse", "HEAD")
|
|
1158
|
+
ignore.write_text(ignore.read_text(encoding="utf-8") + ".devlyn/\n", encoding="utf-8")
|
|
1159
|
+
exclude = self.work / ".git/info/exclude"
|
|
1160
|
+
anchor = (ignore.read_bytes(), exclude.read_bytes(), self.anchor_state())
|
|
1161
|
+
predecessor = None
|
|
1162
|
+
for route, status in (("remote", "COMPLETE"), ("start", "PR"), ("local_base", "LOCAL_ONLY"),
|
|
1163
|
+
("from_receipt", "LOCAL_ONLY")):
|
|
1164
|
+
with self.subTest(route=route):
|
|
1165
|
+
source = {} if route == "remote" else {route: predecessor if route == "from_receipt" else base}
|
|
1166
|
+
self.allocate_local(route, **source)
|
|
1167
|
+
baseline = json.loads(self.receipt.read_text(encoding="utf-8"))["baseline"]
|
|
1168
|
+
(self.task / "product").write_text(route + "\n", encoding="utf-8")
|
|
1169
|
+
self.g("add", "product", work=self.task)
|
|
1170
|
+
self.g("commit", "-m", "scoped product", work=self.task)
|
|
1171
|
+
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
1172
|
+
evidence = self.task / ".devlyn/loop/evidence/check.txt"
|
|
1173
|
+
evidence.parent.mkdir(parents=True, exist_ok=True)
|
|
1174
|
+
evidence.write_text("actual fixture check: " + route + "\n", encoding="utf-8")
|
|
1175
|
+
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), route + "\n")
|
|
1176
|
+
self.acceptance = self.task / ".devlyn/acceptance.json"
|
|
1177
|
+
self.acceptance.write_text(json.dumps({"kind": "direct", "task": route, "source_sha": self.sha,
|
|
1178
|
+
"checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/loop/evidence/check.txt"}]}), encoding="utf-8")
|
|
1179
|
+
records = snapshot_files(self.task, [".devlyn"])
|
|
1180
|
+
flags = ["--mode", "auto" if route == "remote" else "pr"] if route in {"remote", "start"} else ["--local-only"]
|
|
1181
|
+
result, _ = self.complete("--writers-stopped", *flags)
|
|
1182
|
+
self.assertEqual(result["status"], status)
|
|
1183
|
+
self.assertEqual(self.g("diff", "--name-only", baseline, self.sha), "product")
|
|
1184
|
+
self.assertEqual(self.g("ls-tree", "-r", "--name-only", self.sha, "--", ".devlyn"), "")
|
|
1185
|
+
verify_files(self.receipt.parent / "custody", {p: v for p, v in records.items() if Path(p) != Path(".devlyn/.gitignore")})
|
|
1186
|
+
if route == "remote":
|
|
1187
|
+
self.assertFalse(self.task.exists())
|
|
1188
|
+
verify_files(self.receipt.parent / "records", records)
|
|
1189
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "diff", "--name-only", base, "main"]).stdout.strip(), "product")
|
|
1190
|
+
elif route == "start":
|
|
1191
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "rev-parse", "refs/heads/task/start"]).stdout.strip(), self.sha)
|
|
1192
|
+
self.assertEqual((ignore.read_bytes(), exclude.read_bytes(), self.anchor_state()), anchor)
|
|
1193
|
+
predecessor = self.receipt
|
|
1194
|
+
|
|
1195
|
+
def test_ignored_base_needs_no_task_ignore_file(self):
|
|
1196
|
+
# Prediction: a base that already ignores both record paths creates no .devlyn directory or ignore file.
|
|
1197
|
+
self.allocate()
|
|
1198
|
+
self.assertFalse((self.task / ".devlyn").exists())
|
|
1199
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1200
|
+
|
|
1201
|
+
@unittest.skipIf(os.name == "nt", "the dirty checkout uses a POSIX shell hook")
|
|
1202
|
+
def test_ignored_base_with_unrelated_checkout_dirt_is_owned(self):
|
|
1203
|
+
# Prediction: an already-ignoring base reaches owned despite unrelated checkout-hook dirt,
|
|
1204
|
+
# without creating .devlyn/.gitignore; round 0 refuses it because it checks the whole tree.
|
|
1205
|
+
hook = self.work / ".git/hooks/post-checkout"
|
|
1206
|
+
hook.write_text("#!/bin/sh\nprintf 'checkout dirt\\n' > unrelated\n", encoding="utf-8")
|
|
1207
|
+
hook.chmod(0o755)
|
|
1208
|
+
result = self.allocate()
|
|
1209
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1210
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
|
|
1211
|
+
self.assertFalse((self.task / ".devlyn/.gitignore").exists())
|
|
1212
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "?? unrelated")
|
|
1213
|
+
|
|
1214
|
+
def test_allocation_refuses_existing_devlyn_paths(self):
|
|
1215
|
+
# Prediction: a tracked .devlyn file, symlink, submodule or non-covering .gitignore is refused with a named
|
|
1216
|
+
# repair, remains untouched, and never becomes an owned allocation.
|
|
1217
|
+
base = self.g("rev-parse", "HEAD")
|
|
1218
|
+
for kind in ("file", "symlink", "ignore", "submodule"):
|
|
1219
|
+
with self.subTest(kind=kind):
|
|
1220
|
+
self.g("reset", "--hard", base)
|
|
1221
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1222
|
+
path = self.work / ".devlyn"
|
|
1223
|
+
if kind == "submodule":
|
|
1224
|
+
self.g("update-index", "--add", "--cacheinfo", f"160000,{base},.devlyn")
|
|
1225
|
+
path.mkdir()
|
|
1226
|
+
else:
|
|
1227
|
+
if kind == "file":
|
|
1228
|
+
path.write_text("owned file\n", encoding="utf-8")
|
|
1229
|
+
elif kind == "symlink":
|
|
1230
|
+
path.symlink_to("product")
|
|
1231
|
+
else:
|
|
1232
|
+
path.mkdir()
|
|
1233
|
+
(path / ".gitignore").write_text("unrelated\n", encoding="utf-8")
|
|
1234
|
+
self.g("add", ".devlyn")
|
|
1235
|
+
self.g("commit", "-am", "existing .devlyn " + kind)
|
|
1236
|
+
start = self.g("rev-parse", "HEAD")
|
|
1237
|
+
tree = self.root / kind
|
|
1238
|
+
result, run = self.cli("allocate", "--repo", self.work, "--task", kind, "--branch", "task/" + kind,
|
|
1239
|
+
"--worktree", tree, "--local-base", start, success=False)
|
|
1240
|
+
self.assertNotEqual(run.returncode, 0)
|
|
1241
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1242
|
+
self.assertIn(str(tree / ".devlyn"), result["reason"])
|
|
1243
|
+
self.assertIn("starting commit", result["reason"])
|
|
1244
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=tree), "")
|
|
1245
|
+
if kind == "ignore":
|
|
1246
|
+
self.assertEqual((tree / ".devlyn/.gitignore").read_text(encoding="utf-8"), "unrelated\n")
|
|
1247
|
+
receipt = self.work / ".git/devlyn-completion" / hashlib.sha256(("task/" + kind).encode()).hexdigest()[:24] / "receipt.json"
|
|
1248
|
+
self.assertEqual(json.loads(receipt.read_text(encoding="utf-8"))["allocation"], "allocating")
|
|
1249
|
+
self.assertIn(f"remove worktree {tree} and branch task/{kind}, delete receipt directory {receipt.parent}, then allocate again", result["reason"])
|
|
1250
|
+
|
|
1251
|
+
def test_sparse_allocation_refuses_hidden_tracked_devlyn_paths(self):
|
|
1252
|
+
# Prediction: sparse checkout hiding a tracked .devlyn file/symlink or .devlyn/.gitignore (including a case
|
|
1253
|
+
# alias with core.ignorecase=true) refuses allocation before creating either path; contents/index stay unchanged.
|
|
1254
|
+
base = self.g("rev-parse", "HEAD")
|
|
1255
|
+
ignorecase = self.run_cmd(["git", "-C", str(self.work), "config", "--local", "--get", "core.ignorecase"], success=False)
|
|
1256
|
+
self.assertIn(ignorecase.returncode, (0, 1))
|
|
1257
|
+
for kind in ("file", "symlink", "ignore", "ignore_alias"):
|
|
1258
|
+
with self.subTest(kind=kind):
|
|
1259
|
+
self.g("reset", "--hard", base)
|
|
1260
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1261
|
+
path = self.work / ".devlyn"
|
|
1262
|
+
if kind == "file":
|
|
1263
|
+
path.write_text("owned file\n", encoding="utf-8")
|
|
1264
|
+
elif kind == "symlink":
|
|
1265
|
+
path.symlink_to("product")
|
|
1266
|
+
else:
|
|
1267
|
+
path.mkdir()
|
|
1268
|
+
(path / (".GITIGNORE" if kind == "ignore_alias" else ".gitignore")).write_text("unrelated\n", encoding="utf-8")
|
|
1269
|
+
self.g("add", "-f", ".devlyn")
|
|
1270
|
+
self.g("commit", "-am", "tracked .devlyn " + kind)
|
|
1271
|
+
start = self.g("rev-parse", "HEAD")
|
|
1272
|
+
self.g("sparse-checkout", "init", "--no-cone")
|
|
1273
|
+
try:
|
|
1274
|
+
if kind == "ignore_alias":
|
|
1275
|
+
self.g("config", "core.ignorecase", "true")
|
|
1276
|
+
self.g("sparse-checkout", "set", "/product")
|
|
1277
|
+
self.assertFalse(os.path.lexists(path))
|
|
1278
|
+
tree = self.root / kind
|
|
1279
|
+
result, run = self.cli("allocate", "--repo", self.work, "--task", kind, "--branch", "task/" + kind,
|
|
1280
|
+
"--worktree", tree, "--local-base", start, success=False)
|
|
1281
|
+
self.assertNotEqual(run.returncode, 0)
|
|
1282
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1283
|
+
self.assertIn(str(tree / ".devlyn"), result["reason"])
|
|
1284
|
+
receipt_dir = self.work / ".git/devlyn-completion" / hashlib.sha256(("task/" + kind).encode()).hexdigest()[:24]
|
|
1285
|
+
self.assertIn(f"remove worktree {tree} and branch task/{kind}, delete receipt directory {receipt_dir}, then allocate again", result["reason"])
|
|
1286
|
+
self.assertFalse(os.path.lexists(tree / ".devlyn"))
|
|
1287
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=tree), "")
|
|
1288
|
+
self.assertEqual(self.g("write-tree", work=tree), self.g("rev-parse", start + "^{tree}"))
|
|
1289
|
+
finally:
|
|
1290
|
+
self.g("sparse-checkout", "disable")
|
|
1291
|
+
if kind == "ignore_alias":
|
|
1292
|
+
if ignorecase.returncode == 0:
|
|
1293
|
+
self.g("config", "core.ignorecase", ignorecase.stdout.strip())
|
|
1294
|
+
else:
|
|
1295
|
+
self.g("config", "--unset", "core.ignorecase")
|
|
1296
|
+
|
|
1297
|
+
def test_case_sensitive_allocation_allows_distinct_devlyn_paths(self):
|
|
1298
|
+
# Prediction: with core.ignorecase=false, tracked .DEVLYN and .devlyn/.GITIGNORE are distinct from the
|
|
1299
|
+
# record paths: both allocate as owned, preserve the tracked file, and gain an ignored .devlyn/.gitignore.
|
|
1300
|
+
# Round 1 refuses both because its index guards always ignore case.
|
|
1301
|
+
with tempfile.TemporaryDirectory(dir=self.root) as scratch:
|
|
1302
|
+
probe = Path(scratch)
|
|
1303
|
+
(probe / ".DEVLYN").write_text("probe\n", encoding="utf-8")
|
|
1304
|
+
if (probe / ".devlyn").exists():
|
|
1305
|
+
self.skipTest("distinct .DEVLYN/.devlyn paths require a case-sensitive filesystem")
|
|
1306
|
+
(probe / ".GITIGNORE").write_text("probe\n", encoding="utf-8")
|
|
1307
|
+
if (probe / ".gitignore").exists():
|
|
1308
|
+
self.skipTest("distinct .GITIGNORE/.gitignore paths require a case-sensitive filesystem")
|
|
1309
|
+
self.g("config", "core.ignorecase", "false")
|
|
1310
|
+
base = self.g("rev-parse", "HEAD")
|
|
1311
|
+
for kind, relative in (("file_alias", ".DEVLYN"), ("ignore_alias", ".devlyn/.GITIGNORE")):
|
|
1312
|
+
with self.subTest(kind=kind):
|
|
1313
|
+
self.g("reset", "--hard", base)
|
|
1314
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1315
|
+
path = self.work / relative
|
|
1316
|
+
path.parent.mkdir(exist_ok=True)
|
|
1317
|
+
path.write_text("tracked alias\n", encoding="utf-8")
|
|
1318
|
+
self.g("add", "-f", relative)
|
|
1319
|
+
self.g("commit", "-am", "distinct .devlyn " + kind)
|
|
1320
|
+
result = self.allocate_local(kind, local_base=self.g("rev-parse", "HEAD"))
|
|
1321
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1322
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
|
|
1323
|
+
self.assertEqual((self.task / relative).read_text(encoding="utf-8"), "tracked alias\n")
|
|
1324
|
+
self.assertEqual((self.task / ".devlyn/.gitignore").read_text(encoding="utf-8"), "*\n")
|
|
1325
|
+
for record in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
|
|
1326
|
+
self.g("check-ignore", "-q", "--", record, work=self.task)
|
|
1327
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1328
|
+
|
|
1329
|
+
def test_allocation_with_literal_pathspecs_ignores_records(self):
|
|
1330
|
+
# Prediction: GIT_LITERAL_PATHSPECS=1 still allows an unignored base to allocate as owned and ignore records,
|
|
1331
|
+
# including with core.ignorecase=true; round 1 fails at check-ignore with exit 128.
|
|
1332
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1333
|
+
self.g("commit", "-am", "base without .devlyn ignore")
|
|
1334
|
+
self.g("config", "core.ignorecase", "true")
|
|
1335
|
+
self.env["GIT_LITERAL_PATHSPECS"] = "1"
|
|
1336
|
+
result = self.allocate_local("literal", local_base=self.g("rev-parse", "HEAD"))
|
|
1337
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1338
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
|
|
1339
|
+
self.assertEqual((self.task / ".devlyn/.gitignore").read_text(encoding="utf-8"), "*\n")
|
|
1340
|
+
for record in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
|
|
1341
|
+
self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
|
|
1342
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1343
|
+
|
|
1109
1344
|
def test_allocate_base_with_same_named_tag(self):
|
|
1110
1345
|
self.g("tag", "main")
|
|
1111
1346
|
result = self.allocate()
|
|
@@ -106,10 +106,21 @@ require the affected checks again.
|
|
|
106
106
|
Keep the original edits through pending or failed delivery; only reconciliation
|
|
107
107
|
removes them. Preserve all other work and report anything left behind.
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
build
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
Build in the task worktree's own default build directory, never in one
|
|
110
|
+
another checkout builds in, even one build at a time: tools such as Cargo take
|
|
111
|
+
one checkout's outputs as fresh for the other. When a Cargo workspace's
|
|
112
|
+
Cargo.toml sets `[workspace.metadata.devlyn] warm-build-dirs = true`,
|
|
113
|
+
allocation seeds that directory from a built checkout of the repository, so
|
|
114
|
+
the first build does not rebuild every dependency; `build_dirs` in its result
|
|
115
|
+
and receipt says what it did, and a skip means an ordinary cold build. The
|
|
116
|
+
opt-in is the workspace's: seeding keeps dependency outputs that may hold the
|
|
117
|
+
other checkout's paths, and those follow that checkout's later rebuilds. `warm-build-dirs.py <worktree>` does the same for a
|
|
118
|
+
worktree made without allocation. A warmed directory leaves with the worktree
|
|
119
|
+
after merge, so a pending, failed or local-only task keeps it.
|
|
120
|
+
|
|
121
|
+
Allocation also returns a receipt-owned `scratch` directory for other
|
|
122
|
+
disposable build files, such as writable tool caches. Keep source checkouts,
|
|
123
|
+
Git data, lockfiles, reports and restart evidence outside scratch. Do not create unowned
|
|
113
124
|
temporary build trees or preserve them by copying them into evidence custody.
|
|
114
125
|
Prefer `TemporaryDirectory`/`finally` for short-lived test fixtures.
|
|
115
126
|
|
|
@@ -139,7 +150,7 @@ inputs when needed; cleanup is not an instruction to restart parked work.
|
|
|
139
150
|
In the allocated worktree, finish the candidate's decisive checks and diff
|
|
140
151
|
review, then stage and commit only accepted task changes. The helper never
|
|
141
152
|
stages product files. Write a
|
|
142
|
-
root acceptance file in the
|
|
153
|
+
root acceptance file in the task worktree (`.devlyn/acceptance.json`):
|
|
143
154
|
|
|
144
155
|
```json
|
|
145
156
|
{"kind":"direct","task":"<task identity>","source_sha":"<full commit>","checks":[{"command":"<actual check>","evidence":".devlyn/checks.log"}]}
|