devlyn-cli 4.3.0 → 4.3.4
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/cleanup-history.json +76 -1
- package/bin/devlyn.js +172 -48
- package/bin/skill-history.json +133 -2
- package/config/skills/_shared/harness-store.py +8 -2
- package/config/skills/_shared/platform-support.py +12 -0
- package/config/skills/_shared/task-completion.md +6 -3
- package/config/skills/_shared/warm-build-dirs.py +97 -2
- package/config/skills/devlyn-ideate/scripts/queue.py +10 -1
- package/harness/AGENTS.md +37 -0
- package/harness/CLAUDE.md +37 -0
- package/native/claude/.claude-plugin/plugin.json +6 -0
- package/native/claude/skills/_shared/.ruff.toml +2 -0
- package/native/claude/skills/_shared/adapters/claude.md +15 -0
- package/native/claude/skills/_shared/adapters/codex.md +14 -0
- package/native/claude/skills/_shared/adapters/omp.md +6 -0
- package/native/claude/skills/_shared/codex-monitored.sh +263 -0
- package/native/claude/skills/_shared/engine-doctor.sh +70 -0
- package/native/claude/skills/_shared/engine-preflight.md +29 -0
- package/native/claude/skills/_shared/expected-contract.py +242 -0
- package/native/claude/skills/_shared/expected.schema.json +120 -0
- package/native/claude/skills/_shared/harness-store.py +207 -0
- package/native/claude/skills/_shared/invocation-receipt.py +499 -0
- package/native/claude/skills/_shared/platform-support.py +407 -0
- package/native/claude/skills/_shared/role-config.py +247 -0
- package/native/claude/skills/_shared/runtime-principles.md +35 -0
- package/native/claude/skills/_shared/task-complete.py +2865 -0
- package/native/claude/skills/_shared/task-completion.md +249 -0
- package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/native/codex/.agents/plugins/marketplace.json +12 -0
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +207 -0
- package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +407 -0
- package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/package.json +1 -1
- package/release.json +1630 -0
|
@@ -0,0 +1,2865 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Outer-owner delivery and recoverable cleanup (task-completion spec, R1–7).
|
|
3
|
+
|
|
4
|
+
No phase/state writer, staging, scheduler, or implicit adoption. See
|
|
5
|
+
_shared/task-completion.md for the owner acceptance contract.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import functools
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import runpy
|
|
18
|
+
import shlex
|
|
19
|
+
import shutil
|
|
20
|
+
import stat
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import unittest
|
|
26
|
+
import uuid
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CompletionError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WritersUnobservable(CompletionError):
|
|
34
|
+
"""Writer cessation cannot be observed here, so owned resources are retained, never deleted."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WriterActive(CompletionError):
|
|
38
|
+
"""An observed process still uses the owned files: wait for it or stop it, then resume."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require(condition, message):
|
|
42
|
+
if not condition:
|
|
43
|
+
raise CompletionError(message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def command(argv, *, cwd=None, ok=(0,)):
|
|
47
|
+
result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
|
|
48
|
+
require(result.returncode in ok, f"{shlex.join(argv[:4])}: {result.stderr.strip() or result.stdout.strip()}")
|
|
49
|
+
return result.stdout.removesuffix("\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def git(work, *args):
|
|
53
|
+
return command(["git", "-C", str(work), *args])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@functools.lru_cache(maxsize=None)
|
|
57
|
+
def shared(name):
|
|
58
|
+
return runpy.run_path(str(Path(__file__).with_name(name + ".py")))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_json(path):
|
|
62
|
+
require(stat.S_ISREG(path.lstat().st_mode), f"JSON must be a nonsymlink regular file: {path}")
|
|
63
|
+
return shared("expected-contract")["loads_strict_json"](path.read_text(encoding="utf-8"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def atomic_json(path, value):
|
|
67
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".receipt-")
|
|
68
|
+
try:
|
|
69
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
70
|
+
json.dump(value, stream, sort_keys=True, indent=2)
|
|
71
|
+
stream.write("\n")
|
|
72
|
+
stream.flush()
|
|
73
|
+
os.fsync(stream.fileno())
|
|
74
|
+
os.replace(temporary, path)
|
|
75
|
+
if os.name != "nt":
|
|
76
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
77
|
+
try:
|
|
78
|
+
os.fsync(directory)
|
|
79
|
+
finally:
|
|
80
|
+
os.close(directory)
|
|
81
|
+
finally:
|
|
82
|
+
Path(temporary).unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def safe_path(root, relative):
|
|
86
|
+
require(root == root.resolve(), f"custody/workspace root traverses a symlink: {root}")
|
|
87
|
+
p = Path(relative)
|
|
88
|
+
require(not p.is_absolute() and p.parts and ".." not in p.parts, f"unsafe relative path: {relative}")
|
|
89
|
+
result = root / p
|
|
90
|
+
require(result.resolve().is_relative_to(root.resolve()), f"path escapes custody: {relative}")
|
|
91
|
+
for ancestor in [result, *result.parents]:
|
|
92
|
+
if ancestor == root:
|
|
93
|
+
break
|
|
94
|
+
require(not ancestor.is_symlink(), f"symlink is not owned evidence: {relative}")
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def file_record(path):
|
|
99
|
+
mode = path.lstat().st_mode
|
|
100
|
+
require(stat.S_ISREG(mode), f"evidence is not a regular file: {path}")
|
|
101
|
+
raw = path.read_bytes()
|
|
102
|
+
return {"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw), "mode": stat.S_IMODE(mode)}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def snapshot_files(root, paths):
|
|
106
|
+
result = {}
|
|
107
|
+
for relative in paths:
|
|
108
|
+
p = safe_path(root, relative)
|
|
109
|
+
if p.is_dir():
|
|
110
|
+
for child in sorted(p.rglob("*")):
|
|
111
|
+
rel = str(child.relative_to(root))
|
|
112
|
+
safe_path(root, rel)
|
|
113
|
+
if not child.is_dir():
|
|
114
|
+
result[rel] = file_record(child)
|
|
115
|
+
else:
|
|
116
|
+
result[str(Path(relative))] = file_record(p)
|
|
117
|
+
require(result, "acceptance has no evidence files")
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def verify_files(root, files):
|
|
122
|
+
for relative, expected in files.items():
|
|
123
|
+
require(file_record(safe_path(root, relative)) == expected, f"evidence bytes/mode changed: {relative}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def custody(work, destination, files):
|
|
127
|
+
# Publish only a complete copy. A hard crash's private staging directory is never reused.
|
|
128
|
+
with tempfile.TemporaryDirectory(prefix=".custody-", dir=destination.parent) as temporary:
|
|
129
|
+
root = Path(temporary)
|
|
130
|
+
for relative, expected in files.items():
|
|
131
|
+
source = safe_path(work, relative)
|
|
132
|
+
target = safe_path(root, relative)
|
|
133
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with source.open("rb") as incoming, target.open("xb") as outgoing:
|
|
135
|
+
shutil.copyfileobj(incoming, outgoing)
|
|
136
|
+
outgoing.flush()
|
|
137
|
+
os.fsync(outgoing.fileno())
|
|
138
|
+
target.chmod(expected["mode"])
|
|
139
|
+
require(file_record(target) == expected, f"custody copy mismatch; retain original: {relative}")
|
|
140
|
+
verify_files(work, files)
|
|
141
|
+
verify_files(root, files)
|
|
142
|
+
root.rename(destination)
|
|
143
|
+
atomic_json(destination.parent / "manifest.json", files)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def gref(receipt, *args):
|
|
147
|
+
return command(["git", "--git-dir", receipt["common_gitdir"], *args])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ref_sha(receipt, ref):
|
|
151
|
+
return command(["git", "--git-dir", receipt["common_gitdir"], "rev-parse", "--verify", "--quiet", ref], ok=(0, 1)) or None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def registrations(receipt):
|
|
155
|
+
rows = {}
|
|
156
|
+
for raw in gref(receipt, "worktree", "list", "--porcelain", "-z").split("\0\0"):
|
|
157
|
+
row = dict(field.partition(" ")[::2] for field in raw.split("\0") if field)
|
|
158
|
+
if "worktree" in row:
|
|
159
|
+
rows[str(Path(row["worktree"]).resolve())] = row
|
|
160
|
+
return rows
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def remote_url(receipt):
|
|
164
|
+
values = [gref(receipt, "remote", "get-url", "--all", receipt["remote"]),
|
|
165
|
+
gref(receipt, "remote", "get-url", "--push", "--all", receipt["remote"])]
|
|
166
|
+
# --get-url expands insteadOf; compare the stored literal remote config as
|
|
167
|
+
# well, while preserving Git's normal transport configuration.
|
|
168
|
+
literal = gref(receipt, "config", "--get-all", "remote." + receipt["remote"] + ".url")
|
|
169
|
+
urls = {"literal": literal, "fetch": values[0], "push": values[1]}
|
|
170
|
+
require(all(repository_from_url(url).lower() == receipt["repository"].lower() for url in urls.values()), "repository differs from remote")
|
|
171
|
+
return urls
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def repository_from_url(url):
|
|
175
|
+
match = re.fullmatch(r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?", url)
|
|
176
|
+
require(match is not None, "remote must unambiguously identify one GitHub repository")
|
|
177
|
+
return match.group(1)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def policy(receipt, override):
|
|
181
|
+
config = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "config", "--local", "--get-all", "devlyn.completionMode"], capture_output=True, text=True, encoding="utf-8")
|
|
182
|
+
require(config.returncode in {0, 1}, "cannot read local completion policy: " + config.stderr.strip())
|
|
183
|
+
require(config.returncode == 1 or config.stdout in {"auto\n", "pr\n"}, "invalid local devlyn.completionMode; use auto|pr")
|
|
184
|
+
value = config.stdout.strip() if config.returncode == 0 else None
|
|
185
|
+
require(override is None or override in {"auto", "pr"}, "invalid task mode; use auto|pr")
|
|
186
|
+
return override or value or "auto"
|
|
187
|
+
|
|
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
|
+
|
|
220
|
+
def allocate(args, *, exclude_receipts=()):
|
|
221
|
+
"""Reconcile older tasks, except receipts the queue has identified as invalid."""
|
|
222
|
+
work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
|
|
223
|
+
common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
|
|
224
|
+
require(args.task.strip(), "task identity is required")
|
|
225
|
+
git(work, "check-ref-format", "refs/heads/" + args.branch)
|
|
226
|
+
if args.base is not None:
|
|
227
|
+
git(work, "check-ref-format", "refs/heads/" + args.base)
|
|
228
|
+
require(args.branch != args.base and args.branch not in {"main", "master"}, "cannot own a base/default branch")
|
|
229
|
+
require(re.fullmatch(r"[A-Za-z0-9_.-]+", args.remote), "unsafe remote name")
|
|
230
|
+
receipt = {"task": args.task, "repository": args.repository, "remote": args.remote,
|
|
231
|
+
"base": args.base, "branch": args.branch, "common_gitdir": str(common),
|
|
232
|
+
"anchor": str(work), "linked": True, "allocation": "allocating"}
|
|
233
|
+
require(not ref_sha(receipt, "refs/heads/"+args.branch), "existing branch cannot be adopted")
|
|
234
|
+
local = local_baseline(receipt, args)
|
|
235
|
+
if local:
|
|
236
|
+
receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
|
|
237
|
+
else:
|
|
238
|
+
require(args.repository and args.base, "a remote allocation needs --repository <owner/repo> and --base <branch>; allocate local work with --local-base or --from-receipt")
|
|
239
|
+
policy(receipt, None)
|
|
240
|
+
receipt["remote_url"] = remote_url(receipt)
|
|
241
|
+
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
242
|
+
receipt["baseline"] = local or exact_commit(receipt, args.start, "--start") or remote_base(receipt)
|
|
243
|
+
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
244
|
+
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")
|
|
245
|
+
receipt["worktree"] = str(target)
|
|
246
|
+
key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
|
|
247
|
+
directory = common / "devlyn-completion" / key
|
|
248
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
249
|
+
path = directory / "receipt.json"
|
|
250
|
+
receipt["id"] = key
|
|
251
|
+
receipt["recovery_ref"] = "refs/devlyn/completed/" + key
|
|
252
|
+
scratch = directory / "scratch"
|
|
253
|
+
scratch.mkdir()
|
|
254
|
+
receipt["scratch_identity"] = workspace_identity(scratch, directory)
|
|
255
|
+
# Persist prospective intent BEFORE native creation. An interrupted allocation
|
|
256
|
+
# stays visibly incomplete; a later call may not adopt whatever now exists.
|
|
257
|
+
atomic_json(path, receipt)
|
|
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
|
|
263
|
+
receipt["worktree_gitdir"] = git(target, "rev-parse", "--absolute-git-dir")
|
|
264
|
+
receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
|
|
265
|
+
receipt["allocation"] = "owned"
|
|
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)
|
|
271
|
+
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
|
|
272
|
+
"build_dirs": owned["build_dirs"],
|
|
273
|
+
"reconciled": [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def exact_commit(receipt, value, flag):
|
|
277
|
+
require(value is None or re.fullmatch(r"[0-9a-f]{40,64}", value) and ref_sha(receipt, value+"^{commit}") == value,
|
|
278
|
+
f"{flag} must name an exact local commit")
|
|
279
|
+
return value
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def local_baseline(receipt, args):
|
|
283
|
+
"""A local loop starts from its recorded base commit or a receipt-bound accepted predecessor, never a fetch."""
|
|
284
|
+
require(sum(map(bool, (args.local_base, args.from_receipt, args.start))) <= 1, "use one of --local-base, --from-receipt or --start")
|
|
285
|
+
if args.from_receipt:
|
|
286
|
+
with locked_receipt(Path(args.from_receipt).absolute(), blocking=False) as predecessor:
|
|
287
|
+
require(predecessor["common_gitdir"] == receipt["common_gitdir"], "predecessor receipt belongs to another repository")
|
|
288
|
+
require(predecessor.get("acceptance") and predecessor["acceptance"].get("verdict") != "FAILED", "predecessor has no accepted result")
|
|
289
|
+
require(ref_sha(predecessor, predecessor["recovery_ref"]) == predecessor["publish_sha"], "predecessor recovery ref changed")
|
|
290
|
+
gref(predecessor, "merge-base", "--is-ancestor", predecessor["source_sha"], predecessor["publish_sha"])
|
|
291
|
+
receipt["allocated_from"] = {"receipt": predecessor["id"], "source_sha": predecessor["source_sha"]}
|
|
292
|
+
return predecessor["source_sha"]
|
|
293
|
+
return exact_commit(receipt, args.local_base, "--local-base")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def refuse_retired_pipeline(receipt, path, supplied, local, flags):
|
|
297
|
+
"""Refuse an unbound acceptance of an archived resolve run before anything is written: binding its archive
|
|
298
|
+
needed the retired helpers. An unreadable acceptance is not classified here; binding reports it as before."""
|
|
299
|
+
if receipt.get("acceptance") or not supplied:
|
|
300
|
+
return
|
|
301
|
+
acceptance_path = Path(supplied).absolute()
|
|
302
|
+
try:
|
|
303
|
+
acceptance = read_json(acceptance_path)
|
|
304
|
+
except (CompletionError, OSError, ValueError):
|
|
305
|
+
return
|
|
306
|
+
if not isinstance(acceptance, dict) or acceptance.get("kind") != "pipeline":
|
|
307
|
+
return
|
|
308
|
+
command = shlex.join(["python3", "package/config/skills/_shared/task-complete.py", "complete", "--receipt", str(path),
|
|
309
|
+
"--acceptance", str(acceptance_path), *(["--local-only"] if local else []), *flags])
|
|
310
|
+
raise CompletionError(
|
|
311
|
+
"pipeline acceptance (an archived resolve run) was retired after devlyn-cli 4.1.0; nothing was changed. Finish the run "
|
|
312
|
+
f"with that release: run `npm pack devlyn-cli@4.1.0`, extract the tarball, then run `{command}` there. "
|
|
313
|
+
+ ("That local-only completion ends as LOCAL_ONLY without binding the acceptance, as every 4.1.0 local-only completion did."
|
|
314
|
+
if local else "That completion binds the pipeline acceptance before publishing; delivery of a receipt it binds then "
|
|
315
|
+
"resumes with this helper."))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def bind_acceptance(receipt, path, supplied):
|
|
319
|
+
work = Path(receipt["worktree"])
|
|
320
|
+
if receipt.get("acceptance"):
|
|
321
|
+
if supplied:
|
|
322
|
+
require(file_record(Path(supplied))["sha256"] == receipt["acceptance_digest"], "acceptance changed; resume with the original acceptance")
|
|
323
|
+
verify_files(path.parent / "custody", receipt["files"])
|
|
324
|
+
return
|
|
325
|
+
require(supplied, "first completion requires explicit root acceptance")
|
|
326
|
+
named = Path(supplied).absolute()
|
|
327
|
+
acceptance_path = named.parent.resolve() / named.name # Its directory's real path, as allocate names the worktree.
|
|
328
|
+
require(acceptance_path.is_relative_to(work), "acceptance must be a regular file in its task checkout")
|
|
329
|
+
safe_path(work, str(acceptance_path.relative_to(work)))
|
|
330
|
+
acceptance = read_json(acceptance_path)
|
|
331
|
+
require(acceptance.get("task") == receipt["task"], "acceptance belongs to another task")
|
|
332
|
+
sha = acceptance.get("source_sha")
|
|
333
|
+
require(isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40,64}", sha), "acceptance must name an exact commit")
|
|
334
|
+
require(gref(receipt, "rev-parse", sha+"^{commit}") == sha, "source is not a commit")
|
|
335
|
+
gref(receipt, "merge-base", "--is-ancestor", receipt["baseline"], sha)
|
|
336
|
+
# A failed loop result is never published, so its checkout stays as the executor left it (as in attach).
|
|
337
|
+
failed = acceptance.get("kind") == "loop" and acceptance.get("verdict") == "FAILED"
|
|
338
|
+
require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == sha and (failed or git(work, "rev-parse", "HEAD") == sha),
|
|
339
|
+
"unverified source delta or changed task ref")
|
|
340
|
+
paths = [str(acceptance_path.relative_to(work))]
|
|
341
|
+
kind = acceptance.get("kind")
|
|
342
|
+
if kind == "direct":
|
|
343
|
+
checks = acceptance.get("checks")
|
|
344
|
+
require(isinstance(checks, list) and checks, "direct acceptance must name actual root-accepted checks")
|
|
345
|
+
for check in checks:
|
|
346
|
+
require(isinstance(check.get("command"), str) and check["command"].strip(), "direct check must name its command")
|
|
347
|
+
paths.append(check["evidence"])
|
|
348
|
+
elif kind == "loop":
|
|
349
|
+
require(acceptance.get("verdict") in {"ACCEPTED", "FAILED"}, "loop result verdict must be ACCEPTED or FAILED")
|
|
350
|
+
evidence = acceptance.get("evidence")
|
|
351
|
+
require(isinstance(evidence, list) and all(isinstance(p, str) for p in evidence), "loop result must list its evidence files")
|
|
352
|
+
paths.extend(evidence)
|
|
353
|
+
else:
|
|
354
|
+
raise CompletionError("acceptance kind must be direct|loop")
|
|
355
|
+
files = snapshot_files(work, paths)
|
|
356
|
+
destination = safe_path(path.parent, "custody")
|
|
357
|
+
if destination.exists():
|
|
358
|
+
# An unbound copy is never authoritative; retain it like an orphan staging directory.
|
|
359
|
+
require(destination.is_dir(), "custody is not a directory; retain and inspect")
|
|
360
|
+
destination.rename(path.parent / (".custody-" + uuid.uuid4().hex))
|
|
361
|
+
custody(work, destination, files)
|
|
362
|
+
recovery = ref_sha(receipt, receipt["recovery_ref"])
|
|
363
|
+
require(recovery in {None, sha}, "recovery ref changed")
|
|
364
|
+
if recovery is None:
|
|
365
|
+
gref(receipt, "update-ref", receipt["recovery_ref"], sha, "0"*len(sha))
|
|
366
|
+
receipt.update(acceptance=acceptance, acceptance_digest=file_record(acceptance_path)["sha256"], source_sha=sha, publish_sha=sha, files=files)
|
|
367
|
+
if acceptance.get("verdict") == "FAILED":
|
|
368
|
+
# Failed results keep custody and a recovery ref, but never become a frontier or a publishable product.
|
|
369
|
+
receipt["product"] = "FAILED"
|
|
370
|
+
atomic_json(path, receipt)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def queue_commit(receipt, work, queue, source):
|
|
374
|
+
safe_path(work, queue["file"])
|
|
375
|
+
commit = queue["commit"]
|
|
376
|
+
require(gref(receipt, "rev-list", "--parents", "-n", "1", commit).split() == [commit, source], "queue commit must be directly atop verified source with one parent")
|
|
377
|
+
require(gref(receipt, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", commit).strip("\0") == queue["file"], "queue commit must change exactly the declared queue file")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def accept(args):
|
|
381
|
+
"""Bind a result's source and evidence custody before terminal metadata or any delivery decision."""
|
|
382
|
+
path = Path(args.receipt).absolute()
|
|
383
|
+
with locked_receipt(path) as receipt:
|
|
384
|
+
refuse_retired_pipeline(receipt, path, args.acceptance, receipt.get("local_only"), [])
|
|
385
|
+
bind_acceptance(receipt, path, args.acceptance)
|
|
386
|
+
return {"status": "FAILED" if receipt["acceptance"].get("verdict") == "FAILED" else "ACCEPTED", "receipt": str(path),
|
|
387
|
+
"source_sha": receipt["source_sha"], "recovery_ref": receipt["recovery_ref"]}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def attach(args):
|
|
391
|
+
"""Attach the queue-only terminal commit to an already bound result; the recovery ref moves with it."""
|
|
392
|
+
path = Path(args.receipt).absolute()
|
|
393
|
+
queue = {"commit": args.commit, "file": args.file}
|
|
394
|
+
with locked_receipt(path) as receipt:
|
|
395
|
+
require(receipt.get("acceptance"), "bind the result before attaching its terminal commit")
|
|
396
|
+
if receipt.get("queue") != queue:
|
|
397
|
+
require(not receipt.get("queue"), "a different terminal commit is already attached")
|
|
398
|
+
work = Path(receipt["worktree"])
|
|
399
|
+
queue_commit(receipt, work, queue, receipt["source_sha"])
|
|
400
|
+
# A failed result is never published, so its checkout stays as left; a publishable one must be checked out.
|
|
401
|
+
require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == args.commit
|
|
402
|
+
and (receipt["acceptance"].get("verdict") == "FAILED" or git(work, "rev-parse", "HEAD") == args.commit),
|
|
403
|
+
"task ref and HEAD must be the terminal commit")
|
|
404
|
+
recovery = ref_sha(receipt, receipt["recovery_ref"])
|
|
405
|
+
require(recovery in {receipt["source_sha"], args.commit}, "recovery ref changed")
|
|
406
|
+
if recovery != args.commit:
|
|
407
|
+
gref(receipt, "update-ref", receipt["recovery_ref"], args.commit, receipt["source_sha"])
|
|
408
|
+
receipt.update(queue=queue, publish_sha=args.commit)
|
|
409
|
+
atomic_json(path, receipt)
|
|
410
|
+
return {"status": "ATTACHED", "receipt": str(path), "source_sha": receipt["source_sha"], "terminal_sha": args.commit}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
PR_FIELDS = "number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def gh(receipt, *args):
|
|
417
|
+
return command(["gh", *args, "--repo", "github.com/"+receipt["repository"]])
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def repository_info(repository):
|
|
421
|
+
info = json.loads(command(["gh", "repo", "view", "github.com/"+repository, "--json", "nameWithOwner,url,defaultBranchRef,mergeCommitAllowed"]))
|
|
422
|
+
require(info["nameWithOwner"].lower() == repository.lower() and info["url"].lower() == "https://github.com/"+repository.lower(), "GitHub repository identity changed")
|
|
423
|
+
return info
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def repo_policy(receipt):
|
|
427
|
+
info = repository_info(receipt["repository"])
|
|
428
|
+
require(info["defaultBranchRef"]["name"] == receipt["base"] and receipt["branch"] != info["defaultBranchRef"]["name"], "base/default branch changed; retain resources")
|
|
429
|
+
return info
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def remote_base(receipt):
|
|
433
|
+
# Never write FETCH_HEAD: a concurrent `git pull` in the anchor would merge ours.
|
|
434
|
+
tracking = "refs/remotes/" + receipt["remote"] + "/" + receipt["base"]
|
|
435
|
+
gref(receipt, "fetch", "--no-tags", "--no-write-fetch-head", receipt["remote"], "+refs/heads/" + receipt["base"] + ":" + tracking)
|
|
436
|
+
return gref(receipt, "rev-parse", "--verify", tracking)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def remote_head(receipt, branch):
|
|
440
|
+
rows = gref(receipt, "ls-remote", "--heads", receipt["remote"], "refs/heads/"+branch).splitlines()
|
|
441
|
+
require(len(rows) <= 1, "ambiguous remote ref")
|
|
442
|
+
return rows[0].split()[0] if rows else None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def validate_pr(receipt, pr):
|
|
446
|
+
owner, name = receipt["repository"].split("/")
|
|
447
|
+
require(pr["url"].lower() == f"https://github.com/{receipt['repository']}/pull/{pr['number']}".lower(), "PR repository mismatch")
|
|
448
|
+
require(pr["headRefName"] == receipt["branch"] and pr["baseRefName"] == receipt["base"] and pr["headRefOid"] == receipt["publish_sha"], "PR head/base/commit changed")
|
|
449
|
+
require(not pr["isCrossRepository"] and pr["headRepository"]["name"].lower() == name.lower() and pr["headRepositoryOwner"]["login"].lower() == owner.lower(), "PR head repository differs")
|
|
450
|
+
require(pr["state"] in {"OPEN", "MERGED"}, "PR is closed without merge; retain task")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def workspace_identity(work, gitdir):
|
|
454
|
+
return [[p.stat().st_dev, p.stat().st_ino] for p in (work, gitdir)]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def inspect_workspace(receipt):
|
|
458
|
+
work = Path(receipt["worktree"])
|
|
459
|
+
row = registrations(receipt).get(str(work))
|
|
460
|
+
require(row and "locked" not in row and "prunable" not in row and "detached" not in row, "worktree missing, detached or locked; retain and inspect registration")
|
|
461
|
+
expected = "refs/heads/" + receipt["branch"]
|
|
462
|
+
require(row.get("branch") == expected, "foreign worktree branch; retain workspace")
|
|
463
|
+
require(git(work, "rev-parse", "--absolute-git-dir") == receipt["worktree_gitdir"] and str(Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()) == receipt["common_gitdir"], "worktree Gitdir ownership changed")
|
|
464
|
+
require(workspace_identity(work, Path(receipt["worktree_gitdir"])) == receipt["worktree_identity"], "worktree/registration was replaced; receipt no longer owns it")
|
|
465
|
+
require(git(work, "rev-parse", "HEAD") == receipt.get("publish_sha", receipt["baseline"]), "task HEAD changed")
|
|
466
|
+
require(not git(work, "status", "--porcelain", "--untracked-files=all"), "dirty/untracked workspace; retain files and commit only accepted scope")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def outside(work):
|
|
470
|
+
require(not Path.cwd().resolve().is_relative_to(work), "caller cwd is inside removable worktree; yield it and resume from outside")
|
|
471
|
+
require(not Path(__file__).resolve().is_relative_to(work), "invoke the installed helper outside the removable worktree so resume remains available")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def removable(receipt, work):
|
|
475
|
+
inspect_workspace(receipt)
|
|
476
|
+
verify_files(work, receipt["files"])
|
|
477
|
+
# Git lists a nested repository or worktree under an ignored path as `dir/`;
|
|
478
|
+
# native removal would delete it with its uncommitted work.
|
|
479
|
+
nested = [p for p in git(work, "ls-files", "--others", "--ignored", "--exclude-standard", "-z").split("\0") if p.endswith("/")]
|
|
480
|
+
require(not nested, "nested repository or worktree inside the task tree: " + ", ".join(nested) + "; retain")
|
|
481
|
+
stopped_writers(work)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def linux_process_status(process):
|
|
485
|
+
# One opened status file is bound to that process, not a reused PID.
|
|
486
|
+
# A zombie leader can still have living sibling threads.
|
|
487
|
+
try:
|
|
488
|
+
return dict(line.split(":", 1) for line in (process / "status").read_text(encoding="utf-8").splitlines() if ":" in line)
|
|
489
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
490
|
+
raise
|
|
491
|
+
except (OSError, UnicodeError) as exc:
|
|
492
|
+
raise WritersUnobservable("unknown process status; retain tree until writer cessation can be established") from exc
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def stopped_writers(work):
|
|
496
|
+
outside(work)
|
|
497
|
+
# The owner assertion covers its actual children; an OS observation catches
|
|
498
|
+
# other currently open files/cwds, not future writers or a universal lease.
|
|
499
|
+
if sys.platform == "darwin":
|
|
500
|
+
result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True, encoding="utf-8")
|
|
501
|
+
if result.returncode not in {0, 1} or result.stderr.strip():
|
|
502
|
+
raise WritersUnobservable("writer observation unavailable; retain tree and inspect writers")
|
|
503
|
+
pid = None
|
|
504
|
+
for line in result.stdout.splitlines():
|
|
505
|
+
if line.startswith("p"):
|
|
506
|
+
pid = int(line[1:])
|
|
507
|
+
elif line.startswith("n") and pid != os.getpid():
|
|
508
|
+
raise WriterActive(f"active process {pid} uses task files; stop/yield actual writers before resume")
|
|
509
|
+
elif sys.platform.startswith("linux"):
|
|
510
|
+
for process in Path("/proc").iterdir():
|
|
511
|
+
if not process.name.isdigit() or int(process.name) == os.getpid():
|
|
512
|
+
continue
|
|
513
|
+
try:
|
|
514
|
+
links = [process / "cwd", *(process / "fd").iterdir()]
|
|
515
|
+
for link in links:
|
|
516
|
+
try:
|
|
517
|
+
target = Path(os.readlink(link))
|
|
518
|
+
except FileNotFoundError:
|
|
519
|
+
if link == process / "cwd" and linux_process_status(process).get("Threads", "").strip() != "1":
|
|
520
|
+
raise WritersUnobservable("unknown process thread access; retain tree until writer cessation can be established")
|
|
521
|
+
continue
|
|
522
|
+
if target.is_absolute() and target.is_relative_to(work):
|
|
523
|
+
raise WriterActive(f"active process {process.name} uses task files; stop/yield it before resume")
|
|
524
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
525
|
+
continue # Process exited during observation.
|
|
526
|
+
except PermissionError as exc:
|
|
527
|
+
reason = "unknown process access; retain tree until writer cessation can be established"
|
|
528
|
+
try:
|
|
529
|
+
status = linux_process_status(process)
|
|
530
|
+
except (FileNotFoundError, ProcessLookupError) as error:
|
|
531
|
+
raise WritersUnobservable(reason) from error
|
|
532
|
+
if status.get("State", "").strip() == "Z (zombie)" and status.get("Threads", "").strip() == "1":
|
|
533
|
+
continue
|
|
534
|
+
raise WritersUnobservable(reason) from exc
|
|
535
|
+
else:
|
|
536
|
+
raise WritersUnobservable("writer observation unsupported on this platform; retain workspace")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def scratch_mounts():
|
|
540
|
+
if sys.platform.startswith("linux"):
|
|
541
|
+
rows = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines()
|
|
542
|
+
paths = []
|
|
543
|
+
for line in rows:
|
|
544
|
+
fields = line.split()
|
|
545
|
+
require(len(fields) >= 6, "cannot parse mount table; retain scratch")
|
|
546
|
+
paths.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4]))
|
|
547
|
+
elif sys.platform == "darwin":
|
|
548
|
+
paths = []
|
|
549
|
+
for line in command(["mount"]).splitlines():
|
|
550
|
+
description, separator, options = line.rpartition(" (")
|
|
551
|
+
candidates = [description[match.end():] for match in re.finditer(" on ", description)]
|
|
552
|
+
require(separator and options.endswith(")") and candidates, "cannot parse mount table; retain scratch")
|
|
553
|
+
# Darwin prints literal paths. Consider every possible separator so
|
|
554
|
+
# ' on ' in a device or directory name can only cause retention.
|
|
555
|
+
paths.extend(candidates)
|
|
556
|
+
else:
|
|
557
|
+
raise CompletionError("mount observation unsupported; retain scratch")
|
|
558
|
+
return [Path(value).resolve() for value in paths]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def raise_walk_error(error):
|
|
562
|
+
raise error
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def records_manifest(root):
|
|
566
|
+
require(root == root.resolve() and root.is_dir(), f"records root must be a nonsymlink directory; repair and resume: {root}")
|
|
567
|
+
devlyn = safe_path(root, ".devlyn")
|
|
568
|
+
if not devlyn.exists():
|
|
569
|
+
return {}
|
|
570
|
+
require(devlyn.is_dir(), f".devlyn records must be a directory; repair and resume: {devlyn}")
|
|
571
|
+
paths = [str((Path(directory) / name).relative_to(root))
|
|
572
|
+
for directory, dirs, files in os.walk(devlyn, onerror=raise_walk_error)
|
|
573
|
+
for name in files + [name for name in dirs if (Path(directory) / name).is_symlink()]]
|
|
574
|
+
return snapshot_files(root, paths) if paths else {}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def clean_scratch(receipt, path, writers_stopped):
|
|
578
|
+
"""Empty the owned build directory; keep its identity across resume/reuse."""
|
|
579
|
+
scratch = path.parent / "scratch"
|
|
580
|
+
if "scratch_identity" not in receipt:
|
|
581
|
+
return {"status": "NOT_OWNED"}
|
|
582
|
+
require(writers_stopped, "stop/yield scratch writers, then use --writers-stopped")
|
|
583
|
+
require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
|
|
584
|
+
identity = receipt["scratch_identity"]
|
|
585
|
+
require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
|
|
586
|
+
require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
|
|
587
|
+
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
|
|
588
|
+
stopped_writers(scratch)
|
|
589
|
+
size = 0
|
|
590
|
+
for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
|
|
591
|
+
require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
|
|
592
|
+
for name in dirs + files:
|
|
593
|
+
item = Path(directory) / name
|
|
594
|
+
info = item.lstat()
|
|
595
|
+
require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
|
|
596
|
+
size += info.st_size if stat.S_ISREG(info.st_mode) else 0
|
|
597
|
+
stopped_writers(scratch)
|
|
598
|
+
require(workspace_identity(scratch, path.parent) == identity, "scratch changed before removal; retain")
|
|
599
|
+
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch mount appeared before removal; retain")
|
|
600
|
+
for item in scratch.iterdir():
|
|
601
|
+
if item.is_dir() and not item.is_symlink():
|
|
602
|
+
shutil.rmtree(item)
|
|
603
|
+
else:
|
|
604
|
+
item.unlink()
|
|
605
|
+
require(workspace_identity(scratch, path.parent) == identity and not any(scratch.iterdir()), "scratch changed during cleanup; retain and inspect")
|
|
606
|
+
result = {"status": "CLEAN", "logical_bytes_removed": size}
|
|
607
|
+
receipt["scratch_cleanup"] = result
|
|
608
|
+
atomic_json(path, receipt)
|
|
609
|
+
return result
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def clean_scratch_command(args):
|
|
613
|
+
path = Path(args.receipt).absolute()
|
|
614
|
+
with locked_receipt(path) as receipt:
|
|
615
|
+
result = clean_scratch(receipt, path, args.writers_stopped)
|
|
616
|
+
return {"status": "SCRATCH_CLEAN" if result["status"] == "CLEAN" else "SCRATCH_NOT_OWNED",
|
|
617
|
+
"receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def cleanup(receipt, path, pr):
|
|
621
|
+
require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
|
|
622
|
+
sha = receipt["publish_sha"]
|
|
623
|
+
branch_ref = "refs/heads/" + receipt["branch"]
|
|
624
|
+
# The recovery ref keeps the accepted commit reachable, so squash and
|
|
625
|
+
# rebase merges need only the merge commit on base.
|
|
626
|
+
require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
|
|
627
|
+
verify_files(path.parent / "custody", receipt["files"])
|
|
628
|
+
require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
|
|
629
|
+
gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], remote_base(receipt))
|
|
630
|
+
gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
|
|
631
|
+
work = Path(receipt["worktree"])
|
|
632
|
+
local = ref_sha(receipt, branch_ref)
|
|
633
|
+
require(local in {None, sha}, "local task ref changed; retain")
|
|
634
|
+
if receipt["linked"]:
|
|
635
|
+
if work.exists():
|
|
636
|
+
require(receipt.get("writers_released"), "wait actual children, stop/yield known writers, then resume with --writers-stopped")
|
|
637
|
+
require(work != Path(receipt["anchor"]) and work != Path(receipt["common_gitdir"]).parent, "cannot remove retained/main checkout")
|
|
638
|
+
removable(receipt, work)
|
|
639
|
+
records = records_manifest(work)
|
|
640
|
+
if records:
|
|
641
|
+
destination = path.parent / "records"
|
|
642
|
+
if os.path.lexists(destination) and records_manifest(destination) != records:
|
|
643
|
+
digest = hashlib.sha256(json.dumps(records, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
|
|
644
|
+
destination = path.parent / ("records." + digest)
|
|
645
|
+
if not os.path.lexists(destination):
|
|
646
|
+
with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
|
|
647
|
+
shutil.copytree(safe_path(work, ".devlyn"), Path(temporary) / ".devlyn", symlinks=True)
|
|
648
|
+
require(records_manifest(Path(temporary)) == records, ".devlyn changed while it was copied; stop its writer and resume")
|
|
649
|
+
Path(temporary).rename(destination)
|
|
650
|
+
require(records_manifest(destination) == records, f"records copy mismatch; repair and resume: {destination}")
|
|
651
|
+
removable(receipt, work)
|
|
652
|
+
require(records_manifest(work) == records, ".devlyn changed after it was copied; stop its writer and resume")
|
|
653
|
+
gref(receipt, "worktree", "remove", str(work))
|
|
654
|
+
else:
|
|
655
|
+
require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
|
|
656
|
+
checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
|
|
657
|
+
require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
|
|
658
|
+
# Check ownership/ref identity once more immediately before each deletion.
|
|
659
|
+
repo_policy(receipt)
|
|
660
|
+
require(remote_url(receipt) == receipt["remote_url"], "remote changed before deletion")
|
|
661
|
+
remote = remote_head(receipt, receipt["branch"])
|
|
662
|
+
require(remote in {None, sha}, "remote branch changed after merge; retain ref")
|
|
663
|
+
if remote == sha:
|
|
664
|
+
# Deletion with an exact lease is the remote compare-and-delete primitive;
|
|
665
|
+
# product publication above never uses force or a lease.
|
|
666
|
+
gref(receipt, "push", f"--force-with-lease={branch_ref}:{sha}", receipt["remote"], ":"+branch_ref)
|
|
667
|
+
if ref_sha(receipt, branch_ref) is not None:
|
|
668
|
+
require(all(row.get("branch") != branch_ref for row in registrations(receipt).values()), "task branch became checked out; retain ref")
|
|
669
|
+
gref(receipt, "update-ref", "-d", branch_ref, sha)
|
|
670
|
+
receipt.pop("workspace_cleanup", None)
|
|
671
|
+
receipt["status"] = "COMPLETE"
|
|
672
|
+
atomic_json(path, receipt)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@contextlib.contextmanager
|
|
676
|
+
def locked_receipt(path, *, blocking=True):
|
|
677
|
+
require(path.name == "receipt.json" and path.parent.parent.name == "devlyn-completion" and path == path.resolve(), "receipt must be its original external nonsymlink path")
|
|
678
|
+
require(not (path.parent / "lock").is_symlink(), "receipt lock must not be a symlink")
|
|
679
|
+
with contextlib.ExitStack() as stack:
|
|
680
|
+
try:
|
|
681
|
+
stack.enter_context(shared("platform-support")["file_lock"](path.parent / "lock", blocking=blocking))
|
|
682
|
+
except (ImportError, OSError, AttributeError) as exc:
|
|
683
|
+
raise CompletionError(f"receipt lock unavailable: {path}: {exc}") from exc
|
|
684
|
+
receipt = read_json(path)
|
|
685
|
+
require(path == Path(receipt["common_gitdir"]) / "devlyn-completion" / receipt["id"] / "receipt.json", "receipt/common Gitdir binding mismatch")
|
|
686
|
+
require(receipt["id"] == hashlib.sha256(receipt["branch"].encode()).hexdigest()[:24] and receipt["branch"] not in {receipt["base"], "main", "master"}, "receipt branch ownership changed")
|
|
687
|
+
require(receipt["allocation"] == "owned", "allocation was interrupted; no historical adoption is permitted")
|
|
688
|
+
yield receipt
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def completion_result(receipt, path, status):
|
|
692
|
+
if receipt.get("delivery") != status:
|
|
693
|
+
receipt["delivery"] = status # Phase-independent checkpoint: the latest delivery outcome.
|
|
694
|
+
atomic_json(path, receipt)
|
|
695
|
+
released = receipt.get("writers_released", False)
|
|
696
|
+
resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
|
|
697
|
+
(["--writers-stopped"] if released else []))
|
|
698
|
+
scratch = {"status": "NOT_OWNED"}
|
|
699
|
+
if "scratch_identity" in receipt:
|
|
700
|
+
try:
|
|
701
|
+
scratch = clean_scratch(receipt, path, released)
|
|
702
|
+
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
|
|
703
|
+
scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
704
|
+
sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
|
|
705
|
+
receipt["scratch_cleanup"] = scratch
|
|
706
|
+
try:
|
|
707
|
+
atomic_json(path, receipt)
|
|
708
|
+
except OSError as record_error:
|
|
709
|
+
scratch["record_error"] = str(record_error)
|
|
710
|
+
retained = scratch["status"] == "RETAINED" or "workspace_cleanup" in receipt
|
|
711
|
+
return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and retained else status,
|
|
712
|
+
"delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
|
|
713
|
+
"acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
|
|
714
|
+
"scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
|
|
715
|
+
"scratch_cleanup": scratch, "workspace_cleanup": receipt.get("workspace_cleanup")}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def reconcile(common, allocated, anchor, *, exclude_receipts=()):
|
|
719
|
+
results = []
|
|
720
|
+
for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
|
|
721
|
+
if path == allocated or path in exclude_receipts:
|
|
722
|
+
continue
|
|
723
|
+
try:
|
|
724
|
+
receipt = read_json(path)
|
|
725
|
+
if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
|
|
726
|
+
continue
|
|
727
|
+
if receipt.get("status") == "COMPLETE" and (not receipt.get("writers_released") or "scratch_identity" not in receipt or receipt.get("scratch_cleanup", {}).get("status") == "CLEAN"):
|
|
728
|
+
continue
|
|
729
|
+
with locked_receipt(path, blocking=False) as receipt:
|
|
730
|
+
if receipt.get("status") == "COMPLETE":
|
|
731
|
+
if receipt.get("writers_released") and "scratch_identity" in receipt and receipt.get("scratch_cleanup", {}).get("status") != "CLEAN":
|
|
732
|
+
results.append(completion_result(receipt, path, "COMPLETE"))
|
|
733
|
+
continue
|
|
734
|
+
if not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
|
|
735
|
+
continue
|
|
736
|
+
pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
|
|
737
|
+
if pr["state"] != "MERGED":
|
|
738
|
+
continue
|
|
739
|
+
validate_pr(receipt, pr)
|
|
740
|
+
require(not receipt["linked"] or Path(receipt["worktree"]) != anchor, "cannot clean the current allocation's anchor checkout: " + str(anchor))
|
|
741
|
+
require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
|
|
742
|
+
repo_policy(receipt)
|
|
743
|
+
receipt["merge"] = pr
|
|
744
|
+
atomic_json(path, receipt)
|
|
745
|
+
cleanup(receipt, path, pr)
|
|
746
|
+
results.append(completion_result(receipt, path, "COMPLETE"))
|
|
747
|
+
except (Exception, SystemExit) as error:
|
|
748
|
+
results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
|
|
749
|
+
return results
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def complete(args):
|
|
753
|
+
path = Path(args.receipt).absolute()
|
|
754
|
+
with locked_receipt(path) as receipt:
|
|
755
|
+
local = args.local_only or receipt.get("local_only")
|
|
756
|
+
refuse_retired_pipeline(receipt, path, args.acceptance, local, [*(["--mode", args.mode] if args.mode and not local else []),
|
|
757
|
+
*(["--writers-stopped"] if args.writers_stopped else [])])
|
|
758
|
+
if args.writers_stopped:
|
|
759
|
+
if receipt["linked"]:
|
|
760
|
+
outside(Path(receipt["worktree"]))
|
|
761
|
+
receipt["writers_released"] = True
|
|
762
|
+
atomic_json(path, receipt)
|
|
763
|
+
def result(status):
|
|
764
|
+
return completion_result(receipt, path, status)
|
|
765
|
+
if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
|
|
766
|
+
return result("FAILED")
|
|
767
|
+
if args.local_only or receipt.get("local_only"):
|
|
768
|
+
require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
|
|
769
|
+
"--local-only cannot rewrite its delivery, resume it without --local-only")
|
|
770
|
+
receipt["local_only"] = True
|
|
771
|
+
atomic_json(path, receipt)
|
|
772
|
+
if args.acceptance or receipt.get("acceptance"):
|
|
773
|
+
# Persist acceptance custody before the local-only return.
|
|
774
|
+
if not receipt.get("acceptance"):
|
|
775
|
+
inspect_workspace(dict(receipt, publish_sha=ref_sha(receipt, "refs/heads/"+receipt["branch"])))
|
|
776
|
+
bind_acceptance(receipt, path, args.acceptance)
|
|
777
|
+
return result("LOCAL_ONLY")
|
|
778
|
+
mode = policy(receipt, args.mode or receipt.get("mode_override"))
|
|
779
|
+
if args.mode:
|
|
780
|
+
receipt["mode_override"] = args.mode
|
|
781
|
+
atomic_json(path, receipt)
|
|
782
|
+
require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
|
|
783
|
+
if receipt.get("status") == "COMPLETE":
|
|
784
|
+
verify_files(path.parent / "custody", receipt["files"])
|
|
785
|
+
return result("COMPLETE")
|
|
786
|
+
if not receipt.get("acceptance"):
|
|
787
|
+
# Before first binding the owner has committed the product; inspect
|
|
788
|
+
# uses the actual branch head until acceptance pins its exact SHA.
|
|
789
|
+
current = dict(receipt, publish_sha=ref_sha(receipt, "refs/heads/"+receipt["branch"]))
|
|
790
|
+
inspect_workspace(current)
|
|
791
|
+
bind_acceptance(receipt, path, args.acceptance)
|
|
792
|
+
info = repo_policy(receipt)
|
|
793
|
+
if receipt.get("pr_number"):
|
|
794
|
+
pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
|
|
795
|
+
else:
|
|
796
|
+
prs = json.loads(gh(receipt, "pr", "list", "--head", receipt["branch"], "--base", receipt["base"], "--state", "all", "--json", PR_FIELDS))
|
|
797
|
+
require(len(prs) <= 1, "multiple PRs for task head/base; retain and resolve ambiguity")
|
|
798
|
+
pr = prs[0] if prs else None
|
|
799
|
+
if pr:
|
|
800
|
+
validate_pr(receipt, pr)
|
|
801
|
+
actual = remote_head(receipt, receipt["branch"])
|
|
802
|
+
if not pr or pr["state"] != "MERGED":
|
|
803
|
+
inspect_workspace(receipt)
|
|
804
|
+
verify_files(Path(receipt["worktree"]), receipt["files"])
|
|
805
|
+
require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
|
|
806
|
+
require(actual is not None or (not pr and not receipt.get("pushed")), "published task ref disappeared; retain and inspect")
|
|
807
|
+
if actual is None:
|
|
808
|
+
gref(receipt, "push", receipt["remote"], receipt["publish_sha"]+":refs/heads/"+receipt["branch"])
|
|
809
|
+
receipt["pushed"] = True
|
|
810
|
+
atomic_json(path, receipt)
|
|
811
|
+
if pr is None:
|
|
812
|
+
gh(receipt, "pr", "create", "--head", receipt["branch"], "--base", receipt["base"], "--title", receipt["task"], "--body", "Accepted task commit: " + receipt["publish_sha"])
|
|
813
|
+
prs = json.loads(gh(receipt, "pr", "list", "--head", receipt["branch"], "--base", receipt["base"], "--state", "all", "--json", PR_FIELDS))
|
|
814
|
+
require(len(prs) == 1, "created PR could not be reconciled; resume from receipt")
|
|
815
|
+
pr = prs[0]
|
|
816
|
+
validate_pr(receipt, pr)
|
|
817
|
+
receipt.update(pr_number=pr["number"], pr_url=pr["url"])
|
|
818
|
+
atomic_json(path, receipt)
|
|
819
|
+
if mode == "pr" and pr["state"] == "OPEN":
|
|
820
|
+
if pr.get("autoMergeRequest"):
|
|
821
|
+
gh(receipt, "pr", "merge", str(pr["number"]), "--disable-auto")
|
|
822
|
+
pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
|
|
823
|
+
validate_pr(receipt, pr)
|
|
824
|
+
require(not pr.get("autoMergeRequest"), "auto-merge request remains on owned PR; retain and inspect")
|
|
825
|
+
if pr["state"] == "OPEN":
|
|
826
|
+
return result("PR")
|
|
827
|
+
if pr["state"] != "MERGED":
|
|
828
|
+
inspect_workspace(receipt)
|
|
829
|
+
verify_files(Path(receipt["worktree"]), receipt["files"])
|
|
830
|
+
require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
|
|
831
|
+
refused = None
|
|
832
|
+
if not pr.get("autoMergeRequest"):
|
|
833
|
+
if not info["mergeCommitAllowed"]:
|
|
834
|
+
refused = "repository disallows merge commits"
|
|
835
|
+
else:
|
|
836
|
+
owner, name = receipt["repository"].split("/")
|
|
837
|
+
auto_allowed = json.loads(command([
|
|
838
|
+
"gh", "api", "graphql", "--hostname", "github.com", "-f",
|
|
839
|
+
"query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){autoMergeAllowed}}",
|
|
840
|
+
"-f", "owner="+owner, "-f", "name="+name,
|
|
841
|
+
"--jq", ".data.repository.autoMergeAllowed | tojson",
|
|
842
|
+
]))
|
|
843
|
+
require(isinstance(auto_allowed, bool), "repository autoMergeAllowed must be a JSON boolean")
|
|
844
|
+
auto = ["--auto"] if auto_allowed else []
|
|
845
|
+
if not auto:
|
|
846
|
+
# UNKNOWN can reach gh's direct merge mutation; wait for a
|
|
847
|
+
# resolved state and permit only gh's immediate-merge set.
|
|
848
|
+
for attempt in range(10):
|
|
849
|
+
state = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", "mergeStateStatus"))["mergeStateStatus"]
|
|
850
|
+
require(isinstance(state, str) and state, "invalid mergeStateStatus")
|
|
851
|
+
if state != "UNKNOWN":
|
|
852
|
+
break
|
|
853
|
+
if attempt < 9:
|
|
854
|
+
time.sleep(2)
|
|
855
|
+
if state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
|
|
856
|
+
refused = "pull request is not mergeable: " + state
|
|
857
|
+
if not refused:
|
|
858
|
+
try:
|
|
859
|
+
gh(receipt, "pr", "merge", str(pr["number"]), *auto, "--merge", "--match-head-commit", receipt["publish_sha"])
|
|
860
|
+
except CompletionError as error:
|
|
861
|
+
refused = str(error)
|
|
862
|
+
pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
|
|
863
|
+
validate_pr(receipt, pr)
|
|
864
|
+
if pr["state"] != "MERGED":
|
|
865
|
+
# Merge only when the repository allows it; a refused request leaves the PR for a person.
|
|
866
|
+
return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
|
|
867
|
+
receipt["merge"] = pr
|
|
868
|
+
atomic_json(path, receipt)
|
|
869
|
+
try:
|
|
870
|
+
cleanup(receipt, path, pr)
|
|
871
|
+
except (WritersUnobservable, WriterActive) as error:
|
|
872
|
+
# Conservative retention: the merge settles delivery; workspace, task refs and custody stay, reported.
|
|
873
|
+
receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
874
|
+
sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
|
|
875
|
+
atomic_json(path, receipt)
|
|
876
|
+
return result("COMPLETE")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def main():
|
|
880
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
881
|
+
parser.add_argument("--self-test", action="store_true")
|
|
882
|
+
actions = parser.add_subparsers(dest="action")
|
|
883
|
+
allocation = actions.add_parser("allocate")
|
|
884
|
+
allocation.add_argument("--repo", default=".")
|
|
885
|
+
for name in ("task", "branch"):
|
|
886
|
+
allocation.add_argument("--"+name, required=True)
|
|
887
|
+
allocation.add_argument("--base")
|
|
888
|
+
allocation.add_argument("--repository")
|
|
889
|
+
allocation.add_argument("--remote", default="origin")
|
|
890
|
+
allocation.add_argument("--worktree", required=True)
|
|
891
|
+
allocation.add_argument("--local-base")
|
|
892
|
+
allocation.add_argument("--from-receipt")
|
|
893
|
+
allocation.add_argument("--start")
|
|
894
|
+
binding = actions.add_parser("accept")
|
|
895
|
+
binding.add_argument("--receipt", required=True)
|
|
896
|
+
binding.add_argument("--acceptance", required=True)
|
|
897
|
+
attachment = actions.add_parser("attach")
|
|
898
|
+
attachment.add_argument("--receipt", required=True)
|
|
899
|
+
attachment.add_argument("--commit", required=True)
|
|
900
|
+
attachment.add_argument("--file", required=True)
|
|
901
|
+
completion = actions.add_parser("complete")
|
|
902
|
+
completion.add_argument("--receipt", required=True)
|
|
903
|
+
completion.add_argument("--acceptance")
|
|
904
|
+
completion.add_argument("--mode", choices=("auto", "pr"))
|
|
905
|
+
completion.add_argument("--local-only", "--no-push", action="store_true")
|
|
906
|
+
completion.add_argument("--writers-stopped", action="store_true")
|
|
907
|
+
scratch_cleanup = actions.add_parser("clean-scratch")
|
|
908
|
+
scratch_cleanup.add_argument("--receipt", required=True)
|
|
909
|
+
scratch_cleanup.add_argument("--writers-stopped", action="store_true")
|
|
910
|
+
args = parser.parse_args()
|
|
911
|
+
if args.self_test:
|
|
912
|
+
return self_test()
|
|
913
|
+
if args.action is None:
|
|
914
|
+
parser.error("allocate, accept, attach, complete or clean-scratch is required")
|
|
915
|
+
try:
|
|
916
|
+
result = {"allocate": allocate, "accept": accept, "attach": attach,
|
|
917
|
+
"clean-scratch": clean_scratch_command, "complete": complete}[args.action](args)
|
|
918
|
+
print(json.dumps(result, sort_keys=True))
|
|
919
|
+
return 0
|
|
920
|
+
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
|
|
921
|
+
result = {"status": "BLOCKED", "reason": str(exc)}
|
|
922
|
+
if args.action != "allocate":
|
|
923
|
+
result["receipt"] = str(Path(args.receipt).absolute())
|
|
924
|
+
if args.action in {"complete", "clean-scratch"}:
|
|
925
|
+
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
|
|
926
|
+
print(json.dumps(result, sort_keys=True))
|
|
927
|
+
return 1
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def self_test(names=None):
|
|
931
|
+
suite = (unittest.TestSuite(CompletionTests(name) for name in names) if names
|
|
932
|
+
else unittest.defaultTestLoader.loadTestsFromTestCase(CompletionTests))
|
|
933
|
+
result = unittest.TextTestRunner(verbosity=1).run(suite)
|
|
934
|
+
if result.wasSuccessful():
|
|
935
|
+
print(f"task-complete self-test: PASS ({result.testsRun} isolated tests)")
|
|
936
|
+
return 0 if result.wasSuccessful() else 1
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
# Fixtures use real Git and isolated config. Wrappers translate only transport
|
|
940
|
+
# to the local bare remote and inject interruption AFTER real effects; gh is fake.
|
|
941
|
+
FAKE_GH = r'''#!/usr/bin/env python3
|
|
942
|
+
import json, os, pathlib, subprocess, sys
|
|
943
|
+
p = pathlib.Path(os.environ['FIXTURE_GH'])
|
|
944
|
+
d = json.loads(p.read_text(encoding="utf-8")); a = sys.argv[1:]
|
|
945
|
+
prs = d.setdefault('prs', []) # PR number n is prs[n-1]; several may be open at once.
|
|
946
|
+
def save(): p.write_text(json.dumps(d), encoding="utf-8")
|
|
947
|
+
def remote(ref):
|
|
948
|
+
r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True, encoding="utf-8")
|
|
949
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
950
|
+
if a[:2] == ['repo', 'view']:
|
|
951
|
+
assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
|
|
952
|
+
print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':d.get('default_branch','main')}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
|
|
953
|
+
elif a[:1] == ['api']:
|
|
954
|
+
assert a == ['api', 'graphql', '--hostname', 'github.com', '-f',
|
|
955
|
+
'query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){autoMergeAllowed}}',
|
|
956
|
+
'-f', 'owner=test', '-f', 'name=project',
|
|
957
|
+
'--jq', '.data.repository.autoMergeAllowed | tojson'], 'unsupported gh api arguments: '+str(a)
|
|
958
|
+
d['auto_setting_reads'] = d.get('auto_setting_reads',0)+1
|
|
959
|
+
save()
|
|
960
|
+
if d.get('auto_setting_error'):
|
|
961
|
+
print('auto-merge setting read failed',file=sys.stderr); sys.exit(1)
|
|
962
|
+
print(d.get('auto_setting_output', json.dumps(d.get('auto_allowed',True))))
|
|
963
|
+
elif a[:2] == ['pr', 'list']:
|
|
964
|
+
assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
|
|
965
|
+
print(json.dumps([pr for pr in prs if pr['headRefName'] == a[a.index('--head')+1]]))
|
|
966
|
+
elif a[:2] == ['pr', 'create']:
|
|
967
|
+
d['creates'] = d.get('creates',0)+1
|
|
968
|
+
head = a[a.index('--head')+1]; base = a[a.index('--base')+1]
|
|
969
|
+
assert not [pr for pr in prs if pr['headRefName'] == head], 'duplicate PR'
|
|
970
|
+
url = 'https://github.com/test/project/pull/%d' % (len(prs)+1)
|
|
971
|
+
prs.append({'number':len(prs)+1,'url':url,'headRefName':head,'baseRefName':base,'headRefOid':remote('refs/heads/'+head),'headRepository':{'name':'project','owner':{'login':'test'}}, 'headRepositoryOwner':{'login':'test'},'isCrossRepository':False,'state':'OPEN','mergedAt':None,'mergeCommit':None,'autoMergeRequest':None})
|
|
972
|
+
save()
|
|
973
|
+
print(url)
|
|
974
|
+
if d.pop('interrupt_create',False): save(); sys.exit(1)
|
|
975
|
+
elif a[:2] == ['pr', 'view']:
|
|
976
|
+
pr = prs[int(a[2])-1]
|
|
977
|
+
fields = a[4]
|
|
978
|
+
assert a[3:] == ['--json', fields, '--repo', 'github.com/test/project']
|
|
979
|
+
assert fields in ('mergeStateStatus', 'number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest')
|
|
980
|
+
if fields == 'mergeStateStatus':
|
|
981
|
+
if d.get('merge_state_error'):
|
|
982
|
+
print('merge state read failed',file=sys.stderr); sys.exit(1)
|
|
983
|
+
if d.get('merge_states'):
|
|
984
|
+
d['merge_state'] = d['merge_states'].pop(0)
|
|
985
|
+
state = d.get('merge_state', 'BLOCKED' if d.get('pending') else 'CLEAN')
|
|
986
|
+
d.setdefault('merge_state_views', []).append(state)
|
|
987
|
+
save()
|
|
988
|
+
print(json.dumps({'mergeStateStatus': state}))
|
|
989
|
+
else:
|
|
990
|
+
print(json.dumps(pr))
|
|
991
|
+
elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
|
|
992
|
+
d['disables'] = d.get('disables',0)+1
|
|
993
|
+
prs[int(a[2])-1]['autoMergeRequest'] = None
|
|
994
|
+
save()
|
|
995
|
+
elif a[:2] == ['pr', 'merge']:
|
|
996
|
+
pr = prs[int(a[2])-1]
|
|
997
|
+
sha = a[a.index('--match-head-commit')+1]
|
|
998
|
+
auto = ['--auto'] if '--auto' in a else []
|
|
999
|
+
assert a[3:] == auto + ['--merge', '--match-head-commit', sha, '--repo', 'github.com/test/project']
|
|
1000
|
+
assert sha == remote('refs/heads/'+pr['headRefName'])
|
|
1001
|
+
d['merges'] = d.get('merges',0)+1
|
|
1002
|
+
d.setdefault('merge_calls', []).append(a)
|
|
1003
|
+
state = d.get('merge_state', 'BLOCKED' if d.get('pending') else 'CLEAN')
|
|
1004
|
+
immediately_mergeable = state in {'CLEAN', 'HAS_HOOKS', 'UNSTABLE'}
|
|
1005
|
+
if auto and not immediately_mergeable and not d.get('auto_allowed',True):
|
|
1006
|
+
save(); print('repository disallows auto merge',file=sys.stderr); sys.exit(1)
|
|
1007
|
+
if d.get('merge_error'):
|
|
1008
|
+
save(); print('merge attempt refused',file=sys.stderr); sys.exit(1)
|
|
1009
|
+
if not auto and not immediately_mergeable and state != 'UNKNOWN':
|
|
1010
|
+
save(); print('pull request is not mergeable: '+state,file=sys.stderr); sys.exit(1)
|
|
1011
|
+
if auto and not immediately_mergeable:
|
|
1012
|
+
pr['autoMergeRequest'] = {'enabledAt':'now'}
|
|
1013
|
+
else:
|
|
1014
|
+
g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
|
|
1015
|
+
base = remote('refs/heads/main')
|
|
1016
|
+
# A three-way merge into the current base, as GitHub makes it; a conflicting PR does not merge.
|
|
1017
|
+
merged = subprocess.run(g+['merge-tree','--write-tree',base,sha],capture_output=True,text=True,encoding="utf-8")
|
|
1018
|
+
if merged.returncode:
|
|
1019
|
+
save(); print('pull request is not mergeable: '+merged.stdout,file=sys.stderr); sys.exit(1)
|
|
1020
|
+
parents = ['-p',base] if d.get('squash') else ['-p',base,'-p',sha]
|
|
1021
|
+
merge = subprocess.check_output(g+['commit-tree',merged.stdout.split()[0],*parents,'-m','merge fixture'],text=True, encoding="utf-8").strip()
|
|
1022
|
+
subprocess.check_call(g+['update-ref','refs/heads/main',merge,base])
|
|
1023
|
+
pr.update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
|
|
1024
|
+
save()
|
|
1025
|
+
if d.pop('interrupt_merge',False): save(); sys.exit(1)
|
|
1026
|
+
else:
|
|
1027
|
+
raise SystemExit('unexpected gh arguments: '+str(a))
|
|
1028
|
+
'''
|
|
1029
|
+
|
|
1030
|
+
GIT_DISPATCH = r'''#!/bin/sh
|
|
1031
|
+
for arg do
|
|
1032
|
+
case "$arg" in push|fetch|ls-remote|remove) exec python3 "$0.py" "$@";; esac
|
|
1033
|
+
done
|
|
1034
|
+
exec "$REAL_GIT" "$@"
|
|
1035
|
+
'''
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
GIT_WRAPPER = r'''#!/usr/bin/env python3
|
|
1039
|
+
import json, os, pathlib, subprocess, sys
|
|
1040
|
+
a = sys.argv[1:]
|
|
1041
|
+
p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text(encoding="utf-8"))
|
|
1042
|
+
if 'push' in a and any(x.startswith('--force-with-lease=') for x in a) and d.pop('remote_delete_race',False):
|
|
1043
|
+
subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['prs'][0]['headRefName'],d['race_sha']])
|
|
1044
|
+
p.write_text(json.dumps(d), encoding="utf-8")
|
|
1045
|
+
for operation in ('push', 'fetch', 'ls-remote'):
|
|
1046
|
+
if operation in a and 'origin' in a:
|
|
1047
|
+
prefix = a[:a.index(operation)]
|
|
1048
|
+
url = subprocess.check_output([os.environ['REAL_GIT'], *prefix, 'remote', 'get-url', *(['--push'] if operation == 'push' else []), '--all', 'origin'], text=True, encoding="utf-8").strip()
|
|
1049
|
+
if url in {'https://github.com/test/project.git', 'git@github.com:test/project.git', 'ssh://git@github.com/test/project.git'}:
|
|
1050
|
+
a[a.index('origin')] = d['bare']
|
|
1051
|
+
break
|
|
1052
|
+
r = subprocess.run([os.environ['REAL_GIT'],*a])
|
|
1053
|
+
event = 'delete' if 'push' in a and any(x.startswith(':refs/') for x in a) else 'push' if 'push' in a else 'remove' if 'worktree' in a and 'remove' in a else None
|
|
1054
|
+
if r.returncode == 0 and event:
|
|
1055
|
+
d = json.loads(p.read_text(encoding="utf-8")); d[event+'s'] = d.get(event+'s',0)+1
|
|
1056
|
+
fail = d.pop('interrupt_'+event,False); p.write_text(json.dumps(d), encoding="utf-8")
|
|
1057
|
+
if fail: sys.exit(1)
|
|
1058
|
+
sys.exit(r.returncode)
|
|
1059
|
+
'''
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
class CompletionTests(unittest.TestCase):
|
|
1063
|
+
def setUp(self):
|
|
1064
|
+
self.temp = tempfile.TemporaryDirectory(prefix="devlyn-completion-")
|
|
1065
|
+
self.addCleanup(self.temp.cleanup)
|
|
1066
|
+
self.root = Path(self.temp.name).resolve()
|
|
1067
|
+
self.work = self.root / "work"
|
|
1068
|
+
self.bare = self.root / "remote.git"
|
|
1069
|
+
self.bin = self.root / "bin"
|
|
1070
|
+
self.bin.mkdir()
|
|
1071
|
+
self.data = self.root / "gh.json"
|
|
1072
|
+
self.data.write_text(json.dumps({"bare": str(self.bare)}), encoding="utf-8")
|
|
1073
|
+
self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
|
|
1074
|
+
"GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
|
|
1075
|
+
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
|
|
1076
|
+
"FIXTURE_GH": str(self.data), "REAL_GIT": shutil.which("git"),
|
|
1077
|
+
"GIT_CONFIG_COUNT": "0", "GIT_ALLOW_PROTOCOL": "file"}
|
|
1078
|
+
for key in list(self.env):
|
|
1079
|
+
if key in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"}:
|
|
1080
|
+
del self.env[key]
|
|
1081
|
+
for name, body in (("gh", FAKE_GH), ("git.py", GIT_WRAPPER), ("git", GIT_DISPATCH)):
|
|
1082
|
+
path = self.bin / name
|
|
1083
|
+
path.write_text(body, encoding="utf-8")
|
|
1084
|
+
path.chmod(0o755)
|
|
1085
|
+
self.env["PATH"] = str(self.bin) + os.pathsep + os.environ["PATH"]
|
|
1086
|
+
self.run_cmd(["git", "init", "--bare", "--initial-branch=main", str(self.bare)])
|
|
1087
|
+
self.run_cmd(["git", "init", "--initial-branch=main", str(self.work)])
|
|
1088
|
+
(self.work / ".gitignore").write_text(".devlyn/\nignored/\nnode_modules/\n.env\n", encoding="utf-8")
|
|
1089
|
+
(self.work / "product").write_text("baseline\n", encoding="utf-8")
|
|
1090
|
+
self.g("add", ".")
|
|
1091
|
+
self.g("commit", "-m", "baseline")
|
|
1092
|
+
self.g("remote", "add", "origin", "https://github.com/test/project.git")
|
|
1093
|
+
self.g("push", "origin", "main")
|
|
1094
|
+
self.configure(pushs=0)
|
|
1095
|
+
|
|
1096
|
+
def run_cmd(self, args, *, cwd=None, success=True):
|
|
1097
|
+
r = subprocess.run(args, cwd=cwd or self.root, env=self.env, capture_output=True, text=True, encoding="utf-8")
|
|
1098
|
+
if success:
|
|
1099
|
+
self.assertEqual(r.returncode, 0, r.stderr + r.stdout)
|
|
1100
|
+
return r
|
|
1101
|
+
|
|
1102
|
+
def g(self, *args, work=None):
|
|
1103
|
+
return self.run_cmd(["git", "-C", str(work or self.work), *args]).stdout.strip()
|
|
1104
|
+
|
|
1105
|
+
def configure(self, **values):
|
|
1106
|
+
d = json.loads(self.data.read_text(encoding="utf-8")); d.update(values)
|
|
1107
|
+
self.data.write_text(json.dumps(d), encoding="utf-8")
|
|
1108
|
+
|
|
1109
|
+
def cli(self, *args, success=True, cwd=None):
|
|
1110
|
+
r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
|
|
1111
|
+
return json.loads(r.stdout), r
|
|
1112
|
+
|
|
1113
|
+
def allocate(self, name="fixture", base="main"):
|
|
1114
|
+
args = ["allocate", "--repo", self.work, "--task", name, "--branch", "task/"+name, "--repository", "test/project", "--base", base,
|
|
1115
|
+
"--worktree", self.root / ("linked" if name == "fixture" else name)]
|
|
1116
|
+
result, _ = self.cli(*args)
|
|
1117
|
+
self.receipt = Path(result["receipt"])
|
|
1118
|
+
self.task = Path(result["worktree"])
|
|
1119
|
+
return result
|
|
1120
|
+
|
|
1121
|
+
def allocate_legacy(self):
|
|
1122
|
+
common = Path(self.g("rev-parse", "--absolute-git-dir"))
|
|
1123
|
+
key = hashlib.sha256(b"task/fixture").hexdigest()[:24]
|
|
1124
|
+
receipt = {"task": "fixture", "repository": "test/project", "remote": "origin", "base": "main", "branch": "task/fixture",
|
|
1125
|
+
"common_gitdir": str(common), "anchor": str(self.work), "linked": False, "allocation": "owned",
|
|
1126
|
+
"baseline": self.g("rev-parse", "HEAD"), "worktree": str(self.work), "id": key,
|
|
1127
|
+
"recovery_ref": "refs/devlyn/completed/"+key, "worktree_gitdir": str(common),
|
|
1128
|
+
"remote_url": dict.fromkeys(("literal", "fetch", "push"), "https://github.com/test/project.git")}
|
|
1129
|
+
self.receipt = common / "devlyn-completion" / key / "receipt.json"
|
|
1130
|
+
self.receipt.parent.mkdir(parents=True)
|
|
1131
|
+
scratch = self.receipt.parent / "scratch"
|
|
1132
|
+
scratch.mkdir()
|
|
1133
|
+
receipt["scratch_identity"] = workspace_identity(scratch, self.receipt.parent)
|
|
1134
|
+
self.g("switch", "-c", "task/fixture")
|
|
1135
|
+
receipt["worktree_identity"] = workspace_identity(self.work, common)
|
|
1136
|
+
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
1137
|
+
self.task = self.work
|
|
1138
|
+
|
|
1139
|
+
def merge_pr(self):
|
|
1140
|
+
self.configure(pending=False)
|
|
1141
|
+
self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha, "--repo", "github.com/test/project"])
|
|
1142
|
+
|
|
1143
|
+
def anchor_state(self, work=None):
|
|
1144
|
+
work = work or self.work
|
|
1145
|
+
return (self.g("symbolic-ref", "HEAD", work=work), self.g("rev-parse", "HEAD", work=work),
|
|
1146
|
+
self.g("status", "--porcelain", "--untracked-files=all", work=work), (work / "product").read_bytes(),
|
|
1147
|
+
(Path(self.g("rev-parse", "--absolute-git-dir", work=work)) / "index").read_bytes())
|
|
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
|
+
|
|
1344
|
+
def test_allocate_base_with_same_named_tag(self):
|
|
1345
|
+
self.g("tag", "main")
|
|
1346
|
+
result = self.allocate()
|
|
1347
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1348
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], "main")
|
|
1349
|
+
|
|
1350
|
+
def test_allocate_base_preserves_unicode_whitespace(self):
|
|
1351
|
+
base = "main\u00a0"
|
|
1352
|
+
self.g("switch", "-c", base)
|
|
1353
|
+
self.g("push", "origin", "HEAD:refs/heads/"+base)
|
|
1354
|
+
result = self.allocate(base=base)
|
|
1355
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1356
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
|
|
1357
|
+
|
|
1358
|
+
def assert_base_identity_rejected(self, actual, base):
|
|
1359
|
+
result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
|
|
1360
|
+
"--branch", "task/fixture", "--repository", "test/project", "--base", base, "--worktree", self.root / "linked", success=False)
|
|
1361
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1362
|
+
self.assertIn("refs/heads/"+base, result["reason"])
|
|
1363
|
+
head = self.run_cmd(["git", "-C", str(self.work), "symbolic-ref", "HEAD"]).stdout
|
|
1364
|
+
self.assertEqual(head, "refs/heads/" + actual + "\n")
|
|
1365
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1366
|
+
self.assertNotEqual(self.run_cmd(["git", "-C", str(self.work), "show-ref", "--verify",
|
|
1367
|
+
"refs/heads/task/fixture"], success=False).returncode, 0)
|
|
1368
|
+
|
|
1369
|
+
def test_allocate_rejects_trimmed_base_alias(self):
|
|
1370
|
+
self.g("switch", "-c", "main\u00a0")
|
|
1371
|
+
self.g("push", "origin", "HEAD:refs/heads/main\u00a0")
|
|
1372
|
+
self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "-d", "refs/heads/main"])
|
|
1373
|
+
self.assert_base_identity_rejected("main\u00a0", "main")
|
|
1374
|
+
|
|
1375
|
+
def test_allocate_rejects_short_base_alias(self):
|
|
1376
|
+
self.g("branch", "heads/main")
|
|
1377
|
+
self.g("tag", "main")
|
|
1378
|
+
self.assert_base_identity_rejected("main", "heads/main")
|
|
1379
|
+
|
|
1380
|
+
def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
|
|
1381
|
+
baseline = self.g("rev-parse", "main")
|
|
1382
|
+
(self.work / "product").write_text("remote advance\n", encoding="utf-8")
|
|
1383
|
+
self.g("commit", "-am", "remote advance")
|
|
1384
|
+
self.g("push", "origin", "main")
|
|
1385
|
+
remote = self.g("rev-parse", "main")
|
|
1386
|
+
self.g("switch", "-c", "busy")
|
|
1387
|
+
(self.work / "product").write_text("local advance\n", encoding="utf-8")
|
|
1388
|
+
self.g("commit", "-am", "local advance")
|
|
1389
|
+
ahead = self.g("rev-parse", "HEAD")
|
|
1390
|
+
(self.work / "product").write_text("busy edits\n", encoding="utf-8")
|
|
1391
|
+
(self.work / "untracked").write_bytes(b"owner data")
|
|
1392
|
+
for name, local in (("behind", baseline), ("ahead", ahead)):
|
|
1393
|
+
self.g("update-ref", "refs/heads/main", local)
|
|
1394
|
+
before = self.anchor_state()
|
|
1395
|
+
result = self.allocate(name)
|
|
1396
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1397
|
+
self.assertEqual(json.loads(self.receipt.read_text())["baseline"], remote)
|
|
1398
|
+
self.assertEqual(self.g("rev-parse", "HEAD", work=self.task), remote)
|
|
1399
|
+
self.assertEqual(self.anchor_state(), before)
|
|
1400
|
+
self.assertEqual((self.work / "untracked").read_bytes(), b"owner data")
|
|
1401
|
+
self.assertFalse((self.work / ".git" / "FETCH_HEAD").exists())
|
|
1402
|
+
|
|
1403
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1404
|
+
def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
|
|
1405
|
+
result = self.allocate()
|
|
1406
|
+
scratch = Path(result["scratch"])
|
|
1407
|
+
(scratch / "target").mkdir()
|
|
1408
|
+
(scratch / "target" / "object").write_bytes(b"rebuildable")
|
|
1409
|
+
(self.task / "uncommitted-work").write_text("preserve")
|
|
1410
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
|
|
1411
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
1412
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
1413
|
+
self.assertEqual((self.task / "uncommitted-work").read_text(), "preserve")
|
|
1414
|
+
self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 11)
|
|
1415
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1416
|
+
self.assertEqual(result["status"], "SCRATCH_CLEAN")
|
|
1417
|
+
|
|
1418
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1419
|
+
def test_scratch_rejects_active_writer_and_requires_owner_assertion(self):
|
|
1420
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1421
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
|
|
1422
|
+
self.assertIn("--writers-stopped", result["reason"])
|
|
1423
|
+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
|
|
1424
|
+
try:
|
|
1425
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
1426
|
+
self.assertIn("active process", result["reason"])
|
|
1427
|
+
self.assertTrue(scratch.is_dir())
|
|
1428
|
+
finally:
|
|
1429
|
+
child.terminate()
|
|
1430
|
+
child.wait(timeout=5)
|
|
1431
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1432
|
+
|
|
1433
|
+
def test_linux_writer_scan_continues_after_vanished_fd(self):
|
|
1434
|
+
from unittest.mock import patch
|
|
1435
|
+
process = Path("/proc") / str(os.getpid() + 1)
|
|
1436
|
+
vanished, active = process / "fd/0", process / "fd/1"
|
|
1437
|
+
iterdir, readlink = Path.iterdir, os.readlink
|
|
1438
|
+
def entries(path):
|
|
1439
|
+
if path == Path("/proc"):
|
|
1440
|
+
return iter([process])
|
|
1441
|
+
if path == process / "fd":
|
|
1442
|
+
return iter([vanished, active])
|
|
1443
|
+
return iterdir(path)
|
|
1444
|
+
def target(path, *args, **kwargs):
|
|
1445
|
+
if path == process / "cwd":
|
|
1446
|
+
return str(self.root)
|
|
1447
|
+
if path == vanished:
|
|
1448
|
+
raise FileNotFoundError(path)
|
|
1449
|
+
if path == active:
|
|
1450
|
+
return str(self.work / "product")
|
|
1451
|
+
return readlink(path, *args, **kwargs)
|
|
1452
|
+
with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(os, "readlink", target):
|
|
1453
|
+
with self.assertRaisesRegex(WriterActive, "active process " + process.name):
|
|
1454
|
+
stopped_writers(self.work)
|
|
1455
|
+
|
|
1456
|
+
def linux_unobservable_process(self, status, *, missing_cwd=False, active_fd=False):
|
|
1457
|
+
from unittest.mock import patch
|
|
1458
|
+
process = Path("/proc") / str(os.getpid() + 1)
|
|
1459
|
+
original_entries, original_text, original_link = Path.iterdir, Path.read_text, os.readlink
|
|
1460
|
+
def entries(path):
|
|
1461
|
+
if path == Path("/proc"):
|
|
1462
|
+
return iter([process])
|
|
1463
|
+
if path == process / "fd":
|
|
1464
|
+
if missing_cwd:
|
|
1465
|
+
return iter([process / "fd/3"] if active_fd else [])
|
|
1466
|
+
raise PermissionError(path)
|
|
1467
|
+
return original_entries(path)
|
|
1468
|
+
def text(path, *args, **kwargs):
|
|
1469
|
+
if path == process / "status":
|
|
1470
|
+
if isinstance(status, Exception):
|
|
1471
|
+
raise status
|
|
1472
|
+
return status
|
|
1473
|
+
return original_text(path, *args, **kwargs)
|
|
1474
|
+
def link(path, *args, **kwargs):
|
|
1475
|
+
if path == process / "cwd":
|
|
1476
|
+
raise FileNotFoundError(path)
|
|
1477
|
+
if path == process / "fd/3":
|
|
1478
|
+
return str(self.work / "product")
|
|
1479
|
+
return original_link(path, *args, **kwargs)
|
|
1480
|
+
with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(Path, "read_text", text), patch.object(os, "readlink", link):
|
|
1481
|
+
stopped_writers(self.work)
|
|
1482
|
+
|
|
1483
|
+
def test_linux_writer_scan_accepts_confirmed_single_thread_zombie(self):
|
|
1484
|
+
self.linux_unobservable_process("Name:\tgit\nState:\tZ (zombie)\nThreads:\t1\n")
|
|
1485
|
+
|
|
1486
|
+
def test_linux_writer_scan_retains_denied_live_or_threaded_process(self):
|
|
1487
|
+
# The leader may be Z after pthread_exit while sibling threads write.
|
|
1488
|
+
for state, threads in (("Z (zombie)", "2"), ("R (running)", "1"), ("S (sleeping)", "1"), ("T (stopped)", "1")):
|
|
1489
|
+
with self.subTest(state=state, threads=threads):
|
|
1490
|
+
with self.assertRaises(WritersUnobservable):
|
|
1491
|
+
self.linux_unobservable_process(f"State:\t{state}\nThreads:\t{threads}\n")
|
|
1492
|
+
|
|
1493
|
+
def test_linux_writer_scan_retains_unknown_zombie_status(self):
|
|
1494
|
+
for status in ("", "State:\tZ (zombie)\n", "Threads:\t1\n", "State:\tZ (zombie)\nThreads:\tunknown\n",
|
|
1495
|
+
PermissionError("status denied"), FileNotFoundError("exited before terminal evidence")):
|
|
1496
|
+
with self.subTest(status=str(status)):
|
|
1497
|
+
with self.assertRaises(WritersUnobservable):
|
|
1498
|
+
self.linux_unobservable_process(status)
|
|
1499
|
+
|
|
1500
|
+
def test_linux_missing_cwd_checks_threads_and_remaining_fds(self):
|
|
1501
|
+
self.linux_unobservable_process("State:\tZ (zombie)\nThreads:\t1\n", missing_cwd=True)
|
|
1502
|
+
self.linux_unobservable_process(FileNotFoundError("process exited"), missing_cwd=True)
|
|
1503
|
+
self.linux_unobservable_process("State:\tS (sleeping)\nThreads:\t1\n", missing_cwd=True)
|
|
1504
|
+
with self.assertRaises(WriterActive):
|
|
1505
|
+
self.linux_unobservable_process("State:\tS (sleeping)\nThreads:\t1\n", missing_cwd=True, active_fd=True)
|
|
1506
|
+
for status in ("State:\tZ (zombie)\nThreads:\t2\n", "State:\tS (sleeping)\nThreads:\t2\n", ""):
|
|
1507
|
+
with self.subTest(status=status):
|
|
1508
|
+
with self.assertRaises(WritersUnobservable):
|
|
1509
|
+
self.linux_unobservable_process(status, missing_cwd=True)
|
|
1510
|
+
|
|
1511
|
+
@unittest.skipUnless(sys.platform.startswith("linux"), "requires Linux proc and unreaped child")
|
|
1512
|
+
def test_linux_real_zombie_allows_only_attested_scratch_cleanup(self):
|
|
1513
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1514
|
+
(scratch / "artifact").write_text("rebuildable")
|
|
1515
|
+
child = os.fork()
|
|
1516
|
+
if child == 0:
|
|
1517
|
+
os._exit(0)
|
|
1518
|
+
try:
|
|
1519
|
+
os.waitid(os.P_PID, child, os.WEXITED | os.WNOWAIT)
|
|
1520
|
+
state = (Path("/proc") / str(child) / "status").read_text()
|
|
1521
|
+
self.assertRegex(state, r"State:\s+Z \(zombie\)")
|
|
1522
|
+
self.assertRegex(state, r"Threads:\s+1\n")
|
|
1523
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
|
|
1524
|
+
self.assertIn("--writers-stopped", result["reason"])
|
|
1525
|
+
self.assertTrue((scratch / "artifact").exists())
|
|
1526
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1527
|
+
self.assertEqual(result["status"], "SCRATCH_CLEAN")
|
|
1528
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
1529
|
+
finally:
|
|
1530
|
+
os.waitpid(child, 0)
|
|
1531
|
+
|
|
1532
|
+
@unittest.skipUnless(sys.platform.startswith("linux"), "requires Linux pthread leader state")
|
|
1533
|
+
def test_linux_real_zombie_leader_retains_live_sibling_writer(self):
|
|
1534
|
+
import time
|
|
1535
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1536
|
+
heartbeat = scratch / "heartbeat"
|
|
1537
|
+
code = """import ctypes, pathlib, sys, threading, time
|
|
1538
|
+
path = pathlib.Path(sys.argv[1])
|
|
1539
|
+
def worker():
|
|
1540
|
+
with path.open('w') as stream:
|
|
1541
|
+
while True:
|
|
1542
|
+
stream.write('x'); stream.flush(); time.sleep(.01)
|
|
1543
|
+
threading.Thread(target=worker).start()
|
|
1544
|
+
ctypes.CDLL(None).pthread_exit(None)
|
|
1545
|
+
"""
|
|
1546
|
+
child = subprocess.Popen([sys.executable, "-c", code, str(heartbeat)], cwd=self.root)
|
|
1547
|
+
try:
|
|
1548
|
+
deadline = time.monotonic() + 5
|
|
1549
|
+
while time.monotonic() < deadline:
|
|
1550
|
+
status = (Path("/proc") / str(child.pid) / "status").read_text()
|
|
1551
|
+
if re.search(r"State:\s+Z \(zombie\)", status) and heartbeat.exists():
|
|
1552
|
+
break
|
|
1553
|
+
time.sleep(.01)
|
|
1554
|
+
else:
|
|
1555
|
+
self.fail("threaded zombie fixture did not become ready")
|
|
1556
|
+
self.assertRegex(status, r"Threads:\s+2\n")
|
|
1557
|
+
before = heartbeat.stat().st_size
|
|
1558
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
1559
|
+
self.assertIn("unknown process", result["reason"])
|
|
1560
|
+
self.assertTrue(heartbeat.exists())
|
|
1561
|
+
deadline = time.monotonic() + 5
|
|
1562
|
+
while heartbeat.stat().st_size <= before and time.monotonic() < deadline:
|
|
1563
|
+
time.sleep(.01)
|
|
1564
|
+
self.assertGreater(heartbeat.stat().st_size, before)
|
|
1565
|
+
finally:
|
|
1566
|
+
child.kill()
|
|
1567
|
+
child.wait(timeout=5)
|
|
1568
|
+
|
|
1569
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1570
|
+
def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
|
|
1571
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1572
|
+
outside = self.root / "outside"
|
|
1573
|
+
outside.mkdir()
|
|
1574
|
+
(outside / "keep").write_text("source")
|
|
1575
|
+
retained = scratch.with_name("retained")
|
|
1576
|
+
scratch.rename(retained)
|
|
1577
|
+
scratch.symlink_to(outside, target_is_directory=True)
|
|
1578
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
1579
|
+
self.assertIn("redirected", result["reason"])
|
|
1580
|
+
scratch.unlink()
|
|
1581
|
+
retained.rename(scratch)
|
|
1582
|
+
(scratch / ".GiT").write_text("gitdir: elsewhere")
|
|
1583
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
1584
|
+
self.assertIn("Git recovery data", result["reason"])
|
|
1585
|
+
(scratch / ".GiT").unlink()
|
|
1586
|
+
(scratch / "external-link").symlink_to(outside, target_is_directory=True)
|
|
1587
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1588
|
+
self.assertEqual((outside / "keep").read_text(), "source")
|
|
1589
|
+
|
|
1590
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1591
|
+
def test_scratch_reuse_and_partial_cleanup_keep_original_identity(self):
|
|
1592
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1593
|
+
(scratch / "first-build").write_bytes(b"first")
|
|
1594
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1595
|
+
(scratch / "resumed-build").write_bytes(b"next")
|
|
1596
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1597
|
+
self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 4)
|
|
1598
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
1599
|
+
scratch.rename(scratch.with_name("original"))
|
|
1600
|
+
scratch.mkdir()
|
|
1601
|
+
(scratch / "foreign").write_text("preserve")
|
|
1602
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
1603
|
+
self.assertIn("replaced", result["reason"])
|
|
1604
|
+
self.assertIn("--writers-stopped", result["resume"])
|
|
1605
|
+
self.assertEqual((scratch / "foreign").read_text(), "preserve")
|
|
1606
|
+
|
|
1607
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1608
|
+
def test_scratch_refusal_does_not_block_local_only_delivery(self):
|
|
1609
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1610
|
+
(scratch / ".git").mkdir()
|
|
1611
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
|
|
1612
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
1613
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
|
|
1614
|
+
self.assertIn("Git recovery data", result["scratch_cleanup"]["reason"])
|
|
1615
|
+
self.assertEqual(json.loads(self.receipt.read_text())["scratch_cleanup"]["status"], "RETAINED")
|
|
1616
|
+
self.assertTrue((scratch / ".git").is_dir())
|
|
1617
|
+
|
|
1618
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1619
|
+
def test_scratch_refusal_reports_delivery_separately(self):
|
|
1620
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1621
|
+
self.accept()
|
|
1622
|
+
(scratch / ".git").mkdir()
|
|
1623
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped", "--mode", "auto")
|
|
1624
|
+
self.assertEqual(result["status"], "CLEANUP_PENDING")
|
|
1625
|
+
self.assertEqual(result["delivery_status"], "COMPLETE")
|
|
1626
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
|
|
1627
|
+
self.assertIn("--writers-stopped", result["scratch_cleanup"]["resume"])
|
|
1628
|
+
self.assertIn("--writers-stopped", result["resume"])
|
|
1629
|
+
(scratch / ".git").rmdir()
|
|
1630
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--writers-stopped")
|
|
1631
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1632
|
+
|
|
1633
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1634
|
+
def test_scratch_mount_and_scan_failure_preserve_contents(self):
|
|
1635
|
+
from unittest.mock import patch
|
|
1636
|
+
scratch = Path(self.allocate()["scratch"])
|
|
1637
|
+
(scratch / "keep").write_text("retained")
|
|
1638
|
+
receipt = json.loads(self.receipt.read_text())
|
|
1639
|
+
with patch.dict(clean_scratch.__globals__, {"scratch_mounts": lambda: [scratch / "same-device-bind"]}):
|
|
1640
|
+
with self.assertRaisesRegex(CompletionError, "mounted filesystem"):
|
|
1641
|
+
clean_scratch(receipt, self.receipt, True)
|
|
1642
|
+
def failed_walk(*args, **kwargs):
|
|
1643
|
+
kwargs["onerror"](PermissionError("unreadable subtree"))
|
|
1644
|
+
with patch.object(os, "walk", failed_walk):
|
|
1645
|
+
with self.assertRaisesRegex(PermissionError, "unreadable subtree"):
|
|
1646
|
+
clean_scratch(receipt, self.receipt, True)
|
|
1647
|
+
self.assertEqual((scratch / "keep").read_text(), "retained")
|
|
1648
|
+
|
|
1649
|
+
def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
|
|
1650
|
+
from unittest.mock import patch
|
|
1651
|
+
target = "/task/scratch/name on inside\\040literal"
|
|
1652
|
+
with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
|
|
1653
|
+
"command": lambda args: "device on source on " + target + " (local, journaled)",
|
|
1654
|
+
}):
|
|
1655
|
+
self.assertIn(Path(target).resolve(), scratch_mounts())
|
|
1656
|
+
alias = self.root / "scratch-alias"
|
|
1657
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
1658
|
+
with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
|
|
1659
|
+
"command": lambda args: "device on " + str(alias / "mounted") + " (local)",
|
|
1660
|
+
}):
|
|
1661
|
+
self.assertEqual(scratch_mounts(), [(self.root / "mounted").resolve()])
|
|
1662
|
+
with patch.object(sys, "platform", "linux"), patch.object(Path, "read_text", return_value="truncated row"):
|
|
1663
|
+
with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
|
|
1664
|
+
scratch_mounts()
|
|
1665
|
+
|
|
1666
|
+
def accept(self):
|
|
1667
|
+
(self.task / "product").write_text("accepted\n", encoding="utf-8")
|
|
1668
|
+
self.g("add", "product", work=self.task)
|
|
1669
|
+
self.g("commit", "-m", "scoped task", work=self.task)
|
|
1670
|
+
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
1671
|
+
evidence = self.task / ".devlyn"
|
|
1672
|
+
evidence.mkdir(exist_ok=True)
|
|
1673
|
+
(evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n", encoding="utf-8")
|
|
1674
|
+
a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
|
|
1675
|
+
"checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
|
|
1676
|
+
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
|
|
1677
|
+
self.acceptance = evidence / "acceptance.json"
|
|
1678
|
+
self.acceptance.write_text(json.dumps(a), encoding="utf-8")
|
|
1679
|
+
|
|
1680
|
+
def complete(self, *args, success=True, cwd=None, acceptance=True):
|
|
1681
|
+
command_args = ["complete", "--receipt", self.receipt]
|
|
1682
|
+
if acceptance:
|
|
1683
|
+
command_args += ["--acceptance", self.acceptance]
|
|
1684
|
+
return self.cli(*command_args, *args, success=success, cwd=cwd)
|
|
1685
|
+
|
|
1686
|
+
def test_reject_repository_rewrite_before_allocation(self):
|
|
1687
|
+
intended = "https://github.com/test/project.git"
|
|
1688
|
+
destination = "https://github.com/test/other.git"
|
|
1689
|
+
self.g("config", "url." + destination + ".insteadOf", intended)
|
|
1690
|
+
self.assertEqual(self.g("config", "--get", "remote.origin.url"), intended)
|
|
1691
|
+
self.assertEqual(self.g("remote", "get-url", "--all", "origin"), destination)
|
|
1692
|
+
self.assertEqual(self.g("remote", "get-url", "--push", "--all", "origin"), destination)
|
|
1693
|
+
local_refs = self.g("show-ref")
|
|
1694
|
+
remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
|
|
1695
|
+
server = self.data.read_bytes()
|
|
1696
|
+
result, r = self.cli("allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--repository", "test/project", "--base", "main", "--worktree", self.root / "linked", success=False)
|
|
1697
|
+
self.assertEqual(self.g("show-ref"), local_refs, r.stdout)
|
|
1698
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout, remote_refs)
|
|
1699
|
+
self.assertEqual(self.data.read_bytes(), server)
|
|
1700
|
+
self.assertEqual(self.g("branch", "--show-current"), "main")
|
|
1701
|
+
self.assertFalse((self.root / "linked").exists())
|
|
1702
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1703
|
+
self.assertNotEqual(r.returncode, 0)
|
|
1704
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1705
|
+
self.assertIn("repository differs from remote", result["reason"])
|
|
1706
|
+
|
|
1707
|
+
def test_same_repository_transport_alias(self):
|
|
1708
|
+
intended = "https://github.com/test/project.git"
|
|
1709
|
+
alias = "git@github.com:test/project.git"
|
|
1710
|
+
self.g("config", "url." + alias + ".insteadOf", intended)
|
|
1711
|
+
self.allocate(); self.accept()
|
|
1712
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
|
|
1713
|
+
result, _ = self.complete("--mode", "pr")
|
|
1714
|
+
self.assertEqual(result["status"], "PR")
|
|
1715
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1716
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1717
|
+
self.assertEqual((d["pushs"], d.get("merges", 0), d["prs"][0]["autoMergeRequest"]), (1, 0, None))
|
|
1718
|
+
|
|
1719
|
+
def test_pr_mode_disables_only_owned_auto_merge(self):
|
|
1720
|
+
self.allocate(); self.accept()
|
|
1721
|
+
self.configure(pending=True)
|
|
1722
|
+
result, _ = self.complete("--mode", "auto")
|
|
1723
|
+
self.assertEqual(result["status"], "PENDING")
|
|
1724
|
+
result, _ = self.complete("--mode", "pr")
|
|
1725
|
+
self.assertEqual(result["status"], "PR")
|
|
1726
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1727
|
+
self.assertEqual((d["disables"], d["prs"][0]["autoMergeRequest"]), (1, None))
|
|
1728
|
+
self.configure(prs=[dict(d["prs"][0], isCrossRepository=True, autoMergeRequest={"enabledAt": "now"})])
|
|
1729
|
+
_, r = self.complete("--mode", "pr", success=False)
|
|
1730
|
+
self.assertIn("PR head repository differs", json.loads(r.stdout)["reason"])
|
|
1731
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1732
|
+
self.assertEqual((d["disables"], d["prs"][0]["autoMergeRequest"]), (1, {"enabledAt": "now"}))
|
|
1733
|
+
|
|
1734
|
+
def test_remote_rewrite_after_allocation_is_rejected(self):
|
|
1735
|
+
self.allocate(); self.accept()
|
|
1736
|
+
self.g("config", "url.git@github.com:test/project.git.insteadOf", "https://github.com/test/project.git")
|
|
1737
|
+
local_refs = self.g("show-ref")
|
|
1738
|
+
remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
|
|
1739
|
+
server = self.data.read_bytes()
|
|
1740
|
+
result, r = self.complete(success=False)
|
|
1741
|
+
self.assertNotEqual(r.returncode, 0)
|
|
1742
|
+
self.assertIn("remote URL changed since allocation", result["reason"])
|
|
1743
|
+
self.assertEqual(self.g("show-ref"), local_refs)
|
|
1744
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout, remote_refs)
|
|
1745
|
+
self.assertEqual(self.data.read_bytes(), server)
|
|
1746
|
+
self.assertTrue(self.task.exists())
|
|
1747
|
+
|
|
1748
|
+
def test_reject_previously_bound_repository_rewrite(self):
|
|
1749
|
+
self.allocate(); self.accept()
|
|
1750
|
+
destination = "https://github.com/test/other.git"
|
|
1751
|
+
self.g("config", "url." + destination + ".insteadOf", "https://github.com/test/project.git")
|
|
1752
|
+
# Reproduce a receipt the old literal-only allocation check accepted.
|
|
1753
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1754
|
+
receipt["remote_url"].update(fetch=destination, push=destination)
|
|
1755
|
+
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
1756
|
+
local_refs = self.g("show-ref")
|
|
1757
|
+
remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
|
|
1758
|
+
server = self.data.read_bytes()
|
|
1759
|
+
result, r = self.complete(success=False)
|
|
1760
|
+
self.assertNotEqual(r.returncode, 0)
|
|
1761
|
+
self.assertIn("repository differs from remote", result["reason"])
|
|
1762
|
+
self.assertEqual(self.g("show-ref"), local_refs)
|
|
1763
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout, remote_refs)
|
|
1764
|
+
self.assertEqual(self.data.read_bytes(), server)
|
|
1765
|
+
self.assertTrue(self.task.exists())
|
|
1766
|
+
|
|
1767
|
+
def test_pr_pending_and_retry(self):
|
|
1768
|
+
self.allocate(); self.accept()
|
|
1769
|
+
result, _ = self.complete("--mode", "pr")
|
|
1770
|
+
self.assertEqual(result["status"], "PR")
|
|
1771
|
+
self.assertTrue(self.task.exists())
|
|
1772
|
+
self.configure(pending=True)
|
|
1773
|
+
for _ in range(2):
|
|
1774
|
+
result, _ = self.complete("--mode", "auto")
|
|
1775
|
+
self.assertEqual(result["status"], "PENDING")
|
|
1776
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1777
|
+
self.assertEqual((d["creates"], d["merges"], d["pushs"]), (1, 1, 1))
|
|
1778
|
+
|
|
1779
|
+
def test_legacy_in_place_retains_checked_out_branch_then_retires_refs(self):
|
|
1780
|
+
self.allocate_legacy(); self.accept()
|
|
1781
|
+
(self.task / "ignored").mkdir()
|
|
1782
|
+
(self.task / "ignored" / "user.txt").write_text("retain me", encoding="utf-8")
|
|
1783
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1784
|
+
self.merge_pr()
|
|
1785
|
+
before = self.anchor_state()
|
|
1786
|
+
receipt, task = self.receipt, self.task
|
|
1787
|
+
result = self.allocate("next")
|
|
1788
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
1789
|
+
self.assertIn(str(task), result["reconciled"][0]["reason"])
|
|
1790
|
+
self.assertIn("switch", result["reconciled"][0]["reason"])
|
|
1791
|
+
self.assertEqual(self.anchor_state(), before)
|
|
1792
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1793
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1794
|
+
self.g("switch", "main")
|
|
1795
|
+
(self.work / "product").write_text("owner edits\n", encoding="utf-8")
|
|
1796
|
+
(self.work / "untracked").write_bytes(b"owner data")
|
|
1797
|
+
before = self.anchor_state()
|
|
1798
|
+
result = self.allocate("retired")
|
|
1799
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
1800
|
+
self.assertEqual(self.anchor_state(), before)
|
|
1801
|
+
self.assertEqual((self.work / "untracked").read_bytes(), b"owner data")
|
|
1802
|
+
self.assertEqual((task / "ignored/user.txt").read_text(encoding="utf-8"), "retain me")
|
|
1803
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1804
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1805
|
+
self.receipt, self.task = receipt, task
|
|
1806
|
+
again, _ = self.complete(acceptance=False)
|
|
1807
|
+
self.assertEqual(again["status"], "COMPLETE")
|
|
1808
|
+
|
|
1809
|
+
def test_pr_mode_cleans_after_human_merge(self):
|
|
1810
|
+
self.allocate(); self.accept()
|
|
1811
|
+
self.complete("--mode", "pr")
|
|
1812
|
+
self.merge_pr()
|
|
1813
|
+
before = self.data.read_bytes()
|
|
1814
|
+
result, _ = self.complete("--mode", "pr", "--writers-stopped")
|
|
1815
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1816
|
+
self.assertFalse(self.task.exists())
|
|
1817
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1818
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1819
|
+
saved, current = json.loads(before), json.loads(self.data.read_bytes())
|
|
1820
|
+
self.assertEqual((current["merges"], current.get("disables", 0)), (saved["merges"], saved.get("disables", 0)))
|
|
1821
|
+
|
|
1822
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1823
|
+
def test_nested_repository_or_worktree_retains_tree(self):
|
|
1824
|
+
self.allocate(); self.accept()
|
|
1825
|
+
_, r = self.cli("allocate", "--repo", self.work, "--task", "inner", "--branch", "task/inner", "--repository", "test/project",
|
|
1826
|
+
"--base", "main", "--worktree", self.task / "ignored" / "inner", success=False)
|
|
1827
|
+
self.assertIn("disjoint from every registered worktree", json.loads(r.stdout)["reason"])
|
|
1828
|
+
nested = self.task / "ignored" / "nested"
|
|
1829
|
+
self.g("worktree", "add", "-b", "nested/work", str(nested), "main")
|
|
1830
|
+
(nested / "product").write_text("unsaved nested work\n", encoding="utf-8")
|
|
1831
|
+
clone = self.task / "ignored" / "clone"
|
|
1832
|
+
self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
|
|
1833
|
+
for remove in (lambda: self.g("worktree", "remove", "--force", str(nested)), lambda: shutil.rmtree(clone)):
|
|
1834
|
+
_, r = self.complete("--mode", "auto", "--writers-stopped", success=False)
|
|
1835
|
+
self.assertIn("nested repository or worktree", json.loads(r.stdout)["reason"])
|
|
1836
|
+
self.assertTrue(self.task.exists())
|
|
1837
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1838
|
+
if nested.exists():
|
|
1839
|
+
self.assertEqual((nested / "product").read_text(encoding="utf-8"), "unsaved nested work\n")
|
|
1840
|
+
remove()
|
|
1841
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
1842
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1843
|
+
self.assertFalse(self.task.exists())
|
|
1844
|
+
|
|
1845
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1846
|
+
def test_default_mode_merges_when_possible_and_cleans(self):
|
|
1847
|
+
self.allocate(); self.accept()
|
|
1848
|
+
result, _ = self.complete("--writers-stopped")
|
|
1849
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1850
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges"), 1)
|
|
1851
|
+
self.assertFalse(self.task.exists())
|
|
1852
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1853
|
+
|
|
1854
|
+
def test_unobservable_writers_settle_a_merged_delivery_and_retain_the_workspace(self):
|
|
1855
|
+
from unittest.mock import patch
|
|
1856
|
+
self.allocate(); self.accept()
|
|
1857
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1858
|
+
self.merge_pr()
|
|
1859
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
1860
|
+
with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env):
|
|
1861
|
+
result = complete(args)
|
|
1862
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
1863
|
+
self.assertIn("writer observation unsupported", result["workspace_cleanup"]["reason"])
|
|
1864
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1865
|
+
self.assertEqual((receipt["delivery"], receipt.get("status")), ("COMPLETE", None))
|
|
1866
|
+
self.assertTrue(self.task.exists())
|
|
1867
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1868
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1869
|
+
|
|
1870
|
+
def test_release_is_refused_from_inside_the_task_tree(self):
|
|
1871
|
+
self.allocate(); self.accept()
|
|
1872
|
+
_, r = self.complete("--mode", "pr", "--writers-stopped", cwd=self.task, success=False)
|
|
1873
|
+
self.assertIn("caller cwd is inside", json.loads(r.stdout)["reason"])
|
|
1874
|
+
self.assertNotIn("writers_released", json.loads(self.receipt.read_text(encoding="utf-8")))
|
|
1875
|
+
|
|
1876
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
1877
|
+
def test_squash_merge_cleans_and_recovery_ref_keeps_commit(self):
|
|
1878
|
+
self.allocate(); self.accept()
|
|
1879
|
+
result, _ = self.complete("--mode", "pr", "--writers-stopped")
|
|
1880
|
+
self.assertEqual(result["status"], "PR")
|
|
1881
|
+
bare = ["git", "--git-dir", str(self.bare)]
|
|
1882
|
+
base = self.run_cmd(bare + ["rev-parse", "refs/heads/main"]).stdout.strip()
|
|
1883
|
+
tree = self.run_cmd(bare + ["rev-parse", self.sha + "^{tree}"]).stdout.strip()
|
|
1884
|
+
squash = self.run_cmd(bare + ["commit-tree", tree, "-p", base, "-m", "squash fixture"]).stdout.strip()
|
|
1885
|
+
self.run_cmd(bare + ["update-ref", "refs/heads/main", squash, base])
|
|
1886
|
+
pr = json.loads(self.data.read_text(encoding="utf-8"))["prs"][0]
|
|
1887
|
+
self.configure(prs=[dict(pr, state="MERGED", mergedAt="now", mergeCommit={"oid": squash})])
|
|
1888
|
+
result, _ = self.complete()
|
|
1889
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1890
|
+
self.assertFalse(self.task.exists())
|
|
1891
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1892
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1893
|
+
self.assertEqual(self.g("rev-parse", json.loads(self.receipt.read_text(encoding="utf-8"))["recovery_ref"]), self.sha)
|
|
1894
|
+
self.assertFalse((self.work / ".git" / "FETCH_HEAD").exists())
|
|
1895
|
+
|
|
1896
|
+
def test_linked_ignored_output_disposed_and_records_preserved(self):
|
|
1897
|
+
self.allocate(); self.accept()
|
|
1898
|
+
for relative, raw in (("node_modules/x", b"build"), (".env", b"SECRET=value\n"), (".devlyn/runs/extra/log", b"extra\x00bytes\n")):
|
|
1899
|
+
path = self.task / relative
|
|
1900
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1901
|
+
path.write_bytes(raw)
|
|
1902
|
+
path.chmod(0o640)
|
|
1903
|
+
records = snapshot_files(self.task, [".devlyn"])
|
|
1904
|
+
(self.task / "untracked").write_bytes(b"owner data")
|
|
1905
|
+
_, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
|
|
1906
|
+
self.assertNotEqual(process.returncode, 0)
|
|
1907
|
+
self.assertTrue(self.task.exists())
|
|
1908
|
+
self.assertEqual((self.task / "untracked").read_bytes(), b"owner data")
|
|
1909
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1910
|
+
(self.task / "untracked").unlink()
|
|
1911
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
1912
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1913
|
+
self.assertFalse(self.task.exists())
|
|
1914
|
+
verify_files(self.receipt.parent / "records", records)
|
|
1915
|
+
self.assertEqual(snapshot_files(self.receipt.parent / "records", [".devlyn"]), records)
|
|
1916
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1917
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1918
|
+
|
|
1919
|
+
def test_reconcile_released_merge_and_retains_without_release(self):
|
|
1920
|
+
self.allocate(); self.accept()
|
|
1921
|
+
result, _ = self.complete("--mode", "pr")
|
|
1922
|
+
self.assertEqual(result["status"], "PR")
|
|
1923
|
+
receipt, task = self.receipt, self.task
|
|
1924
|
+
self.merge_pr()
|
|
1925
|
+
result = self.allocate("unreleased")
|
|
1926
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
1927
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
1928
|
+
self.assertIn("writers", result["reconciled"][0]["reason"])
|
|
1929
|
+
self.assertTrue(task.exists())
|
|
1930
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1931
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1932
|
+
# Reopen only the fixture PR to exercise the owner's pre-merge release.
|
|
1933
|
+
d = json.loads(self.data.read_text())
|
|
1934
|
+
self.configure(prs=[dict(d["prs"][0], state="OPEN", mergedAt=None, mergeCommit=None)])
|
|
1935
|
+
self.receipt, self.task = receipt, task
|
|
1936
|
+
result, _ = self.complete("--mode", "pr", "--writers-stopped")
|
|
1937
|
+
self.assertEqual(result["status"], "PR")
|
|
1938
|
+
self.assertTrue(json.loads(receipt.read_text())["writers_released"])
|
|
1939
|
+
self.merge_pr()
|
|
1940
|
+
original = records_manifest(task)
|
|
1941
|
+
shutil.copytree(task / ".devlyn", receipt.parent / "records/.devlyn")
|
|
1942
|
+
(task / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
1943
|
+
final = records_manifest(task)
|
|
1944
|
+
server = json.loads(self.data.read_text())
|
|
1945
|
+
result = self.allocate("released")
|
|
1946
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
1947
|
+
self.assertFalse(task.exists())
|
|
1948
|
+
self.assertEqual(records_manifest(receipt.parent / "records"), original)
|
|
1949
|
+
self.assertEqual(records_manifest(next(receipt.parent.glob("records.*"))), final)
|
|
1950
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1951
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1952
|
+
current = json.loads(self.data.read_text())
|
|
1953
|
+
self.assertEqual((current["pushs"], current["creates"], current["merges"], current.get("disables", 0)),
|
|
1954
|
+
(server["pushs"], server["creates"], server["merges"], server.get("disables", 0)))
|
|
1955
|
+
|
|
1956
|
+
def test_reconcile_checks_repo_policy_before_cleanup(self):
|
|
1957
|
+
self.allocate(); self.accept()
|
|
1958
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1959
|
+
self.merge_pr()
|
|
1960
|
+
receipt, task = self.receipt, self.task
|
|
1961
|
+
self.configure(default_branch="changed")
|
|
1962
|
+
result = self.allocate("next")
|
|
1963
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
|
|
1964
|
+
self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
|
|
1965
|
+
self.assertTrue(task.exists())
|
|
1966
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
1967
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1968
|
+
|
|
1969
|
+
def test_reconcile_skips_open_and_unaccepted_and_retains_in_use_tree(self):
|
|
1970
|
+
self.allocate(); self.accept()
|
|
1971
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1972
|
+
receipt, task = self.receipt, self.task
|
|
1973
|
+
before = receipt.read_bytes()
|
|
1974
|
+
result = self.allocate("unaccepted")
|
|
1975
|
+
self.assertEqual(result["reconciled"], [])
|
|
1976
|
+
self.assertEqual(receipt.read_bytes(), before)
|
|
1977
|
+
unaccepted, unaccepted_tree = self.receipt, self.task
|
|
1978
|
+
saved = json.loads(unaccepted.read_text())
|
|
1979
|
+
saved.update(pr_number=1, writers_released=True)
|
|
1980
|
+
unaccepted.write_text(json.dumps(saved))
|
|
1981
|
+
unaccepted_before = unaccepted.read_bytes()
|
|
1982
|
+
pr = json.loads(self.data.read_text())["prs"][0]
|
|
1983
|
+
self.configure(prs=[dict(pr, state="CLOSED")])
|
|
1984
|
+
result = self.allocate("closed")
|
|
1985
|
+
self.assertEqual(result["reconciled"], [])
|
|
1986
|
+
self.assertEqual(receipt.read_bytes(), before)
|
|
1987
|
+
self.assertTrue(task.exists())
|
|
1988
|
+
self.configure(prs=[pr])
|
|
1989
|
+
self.merge_pr()
|
|
1990
|
+
self.g("worktree", "lock", str(task))
|
|
1991
|
+
result = self.allocate("locked")
|
|
1992
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
1993
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
1994
|
+
self.assertIn("locked", result["reconciled"][0]["reason"])
|
|
1995
|
+
self.assertTrue(task.exists())
|
|
1996
|
+
self.g("worktree", "unlock", str(task))
|
|
1997
|
+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=task)
|
|
1998
|
+
try:
|
|
1999
|
+
result = self.allocate("active")
|
|
2000
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
2001
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
2002
|
+
self.assertIn("active process", result["reconciled"][0]["reason"])
|
|
2003
|
+
self.assertTrue(task.exists())
|
|
2004
|
+
self.assertIsNone(child.poll())
|
|
2005
|
+
finally:
|
|
2006
|
+
child.terminate(); child.wait(timeout=5)
|
|
2007
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2008
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
2009
|
+
self.assertEqual(unaccepted.read_bytes(), unaccepted_before)
|
|
2010
|
+
self.assertTrue(unaccepted_tree.exists())
|
|
2011
|
+
|
|
2012
|
+
def test_reconcile_already_retired_tree_and_branch(self):
|
|
2013
|
+
self.allocate(); self.accept()
|
|
2014
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2015
|
+
self.merge_pr()
|
|
2016
|
+
receipt, task = self.receipt, self.task
|
|
2017
|
+
self.g("worktree", "remove", str(task))
|
|
2018
|
+
self.g("update-ref", "-d", "refs/heads/task/fixture", self.sha)
|
|
2019
|
+
before = self.anchor_state()
|
|
2020
|
+
result = self.allocate("next")
|
|
2021
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2022
|
+
self.assertFalse(task.exists())
|
|
2023
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2024
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2025
|
+
self.assertEqual(self.anchor_state(), before)
|
|
2026
|
+
|
|
2027
|
+
def test_reconcile_never_removes_allocation_anchor(self):
|
|
2028
|
+
self.allocate(); self.accept()
|
|
2029
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2030
|
+
self.merge_pr()
|
|
2031
|
+
before = self.anchor_state(self.task)
|
|
2032
|
+
result, _ = self.cli("allocate", "--repo", self.task, "--task", "next", "--branch", "task/next",
|
|
2033
|
+
"--repository", "test/project", "--base", "main", "--worktree", self.root / "next")
|
|
2034
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2035
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
2036
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
2037
|
+
self.assertIn("anchor", result["reconciled"][0]["reason"])
|
|
2038
|
+
self.assertEqual(self.anchor_state(self.task), before)
|
|
2039
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2040
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
2041
|
+
|
|
2042
|
+
def test_reconcile_reports_scratch_cleanup_pending(self):
|
|
2043
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2044
|
+
self.accept()
|
|
2045
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2046
|
+
self.merge_pr()
|
|
2047
|
+
receipt, task = self.receipt, self.task
|
|
2048
|
+
(scratch / "object").write_bytes(b"rebuildable")
|
|
2049
|
+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
|
|
2050
|
+
try:
|
|
2051
|
+
result = self.allocate("next")
|
|
2052
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
2053
|
+
row = result["reconciled"][0]
|
|
2054
|
+
self.assertEqual((row["status"], row["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
2055
|
+
self.assertEqual(row["scratch_cleanup"]["status"], "RETAINED")
|
|
2056
|
+
self.assertIn("active process", row["scratch_cleanup"]["reason"])
|
|
2057
|
+
self.assertEqual((scratch / "object").read_bytes(), b"rebuildable")
|
|
2058
|
+
self.assertIsNone(child.poll())
|
|
2059
|
+
finally:
|
|
2060
|
+
child.terminate(); child.wait(timeout=5)
|
|
2061
|
+
self.assertFalse(task.exists())
|
|
2062
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2063
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2064
|
+
self.configure(default_branch="changed")
|
|
2065
|
+
server = json.loads(self.data.read_text())
|
|
2066
|
+
result = self.allocate("retry")
|
|
2067
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2068
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
2069
|
+
self.assertEqual(json.loads(self.data.read_text()), server)
|
|
2070
|
+
self.assertEqual(self.allocate("later")["reconciled"], [])
|
|
2071
|
+
|
|
2072
|
+
def test_reconcile_retries_unattempted_scratch_cleanup(self):
|
|
2073
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2074
|
+
self.accept()
|
|
2075
|
+
self.complete("--mode", "auto", "--writers-stopped")
|
|
2076
|
+
receipt = self.receipt
|
|
2077
|
+
saved = json.loads(receipt.read_text())
|
|
2078
|
+
del saved["scratch_cleanup"]
|
|
2079
|
+
receipt.write_text(json.dumps(saved))
|
|
2080
|
+
(scratch / "object").write_bytes(b"rebuildable")
|
|
2081
|
+
result = self.allocate("next")
|
|
2082
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2083
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
2084
|
+
|
|
2085
|
+
def test_reconcile_retains_busy_receipt_without_waiting(self):
|
|
2086
|
+
self.allocate(); self.accept()
|
|
2087
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2088
|
+
self.merge_pr()
|
|
2089
|
+
code = "import pathlib, runpy, sys, time\nwith runpy.run_path(sys.argv[1])['file_lock'](pathlib.Path(sys.argv[2]), blocking=True):\n print('locked', flush=True)\n time.sleep(30)\n"
|
|
2090
|
+
child = subprocess.Popen([sys.executable, "-c", code, str(Path(__file__).resolve().with_name("platform-support.py")),
|
|
2091
|
+
str(self.receipt.parent / "lock")], stdout=subprocess.PIPE, text=True)
|
|
2092
|
+
try:
|
|
2093
|
+
self.assertEqual(child.stdout.readline().strip(), "locked")
|
|
2094
|
+
process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "allocate", "--repo", str(self.work),
|
|
2095
|
+
"--task", "next", "--branch", "task/next", "--repository", "test/project", "--base", "main",
|
|
2096
|
+
"--worktree", str(self.root / "next")], cwd=self.root, env=self.env, capture_output=True, text=True, timeout=10)
|
|
2097
|
+
self.assertEqual(process.returncode, 0, process.stdout+process.stderr)
|
|
2098
|
+
result = json.loads(process.stdout)
|
|
2099
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2100
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
2101
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
2102
|
+
self.assertIn("lock", result["reconciled"][0]["reason"])
|
|
2103
|
+
self.assertTrue(self.task.exists())
|
|
2104
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2105
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
2106
|
+
self.assertIsNone(child.poll())
|
|
2107
|
+
finally:
|
|
2108
|
+
child.terminate(); child.wait(timeout=5)
|
|
2109
|
+
child.stdout.close()
|
|
2110
|
+
|
|
2111
|
+
def test_reconcile_retains_malformed_receipt_without_failing_allocation(self):
|
|
2112
|
+
self.allocate()
|
|
2113
|
+
receipt, task = self.receipt, self.task
|
|
2114
|
+
receipt.write_text("[]", encoding="utf-8")
|
|
2115
|
+
result = self.allocate("next")
|
|
2116
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2117
|
+
self.assertTrue(self.task.exists())
|
|
2118
|
+
self.assertEqual(len(result["reconciled"]), 1)
|
|
2119
|
+
self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
|
|
2120
|
+
self.assertEqual(result["reconciled"][0]["receipt"], str(receipt))
|
|
2121
|
+
self.assertTrue(result["reconciled"][0]["reason"])
|
|
2122
|
+
self.assertEqual(receipt.read_text(), "[]")
|
|
2123
|
+
self.assertTrue(task.exists())
|
|
2124
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2125
|
+
|
|
2126
|
+
def test_records_refuse_symlink_nonregular_and_tampered_resume(self):
|
|
2127
|
+
self.allocate(); self.accept()
|
|
2128
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2129
|
+
self.merge_pr()
|
|
2130
|
+
other = self.root / "owner-file"
|
|
2131
|
+
other.write_bytes(b"owner data")
|
|
2132
|
+
link = self.task / ".devlyn/link"
|
|
2133
|
+
link.symlink_to(other)
|
|
2134
|
+
result, _ = self.complete(success=False)
|
|
2135
|
+
self.assertIn("path escapes custody", result["reason"])
|
|
2136
|
+
self.assertTrue(self.task.exists())
|
|
2137
|
+
self.assertTrue(link.is_symlink())
|
|
2138
|
+
self.assertEqual(other.read_bytes(), b"owner data")
|
|
2139
|
+
link.unlink()
|
|
2140
|
+
if hasattr(os, "mkfifo"):
|
|
2141
|
+
os.mkfifo(link)
|
|
2142
|
+
result, _ = self.complete(success=False)
|
|
2143
|
+
self.assertIn("regular file", result["reason"])
|
|
2144
|
+
self.assertTrue(self.task.exists())
|
|
2145
|
+
link.unlink()
|
|
2146
|
+
saved = self.receipt.parent / "records/.devlyn"
|
|
2147
|
+
shutil.copytree(self.task / ".devlyn", saved)
|
|
2148
|
+
(saved / "checks.txt").write_bytes(b"tampered copy")
|
|
2149
|
+
final = records_manifest(self.task)
|
|
2150
|
+
result, _ = self.complete()
|
|
2151
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2152
|
+
self.assertFalse(self.task.exists())
|
|
2153
|
+
self.assertEqual((saved / "checks.txt").read_bytes(), b"tampered copy")
|
|
2154
|
+
self.assertEqual(records_manifest(next(self.receipt.parent.glob("records.*"))), final)
|
|
2155
|
+
|
|
2156
|
+
def test_records_resume_preserves_added_and_changed_files(self):
|
|
2157
|
+
self.allocate(); self.accept()
|
|
2158
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2159
|
+
self.merge_pr()
|
|
2160
|
+
extra = self.task / ".devlyn/extra.log"
|
|
2161
|
+
extra.write_bytes(b"before\n")
|
|
2162
|
+
saved = self.receipt.parent / "records"
|
|
2163
|
+
shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
|
|
2164
|
+
original = records_manifest(saved)
|
|
2165
|
+
extra.write_bytes(b"after\n")
|
|
2166
|
+
extra.chmod(0o640)
|
|
2167
|
+
(self.task / ".devlyn/storage-cleanup.json").write_bytes(b'{"status":"retained"}\n')
|
|
2168
|
+
final = records_manifest(self.task)
|
|
2169
|
+
digest = hashlib.sha256(json.dumps(final, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
|
|
2170
|
+
result, _ = self.complete()
|
|
2171
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2172
|
+
self.assertFalse(self.task.exists())
|
|
2173
|
+
self.assertEqual(records_manifest(saved), original)
|
|
2174
|
+
self.assertEqual(records_manifest(self.receipt.parent / ("records." + digest)), final)
|
|
2175
|
+
|
|
2176
|
+
def test_records_resume_preserves_deleted_file_only_in_earlier_copy(self):
|
|
2177
|
+
self.allocate(); self.accept()
|
|
2178
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2179
|
+
self.merge_pr()
|
|
2180
|
+
extra = self.task / ".devlyn/extra.log"
|
|
2181
|
+
extra.write_bytes(b"before\n")
|
|
2182
|
+
saved = self.receipt.parent / "records"
|
|
2183
|
+
shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
|
|
2184
|
+
original = records_manifest(saved)
|
|
2185
|
+
extra.unlink()
|
|
2186
|
+
final = records_manifest(self.task)
|
|
2187
|
+
result, _ = self.complete()
|
|
2188
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2189
|
+
self.assertFalse(self.task.exists())
|
|
2190
|
+
self.assertEqual(records_manifest(saved), original)
|
|
2191
|
+
generation = next(self.receipt.parent.glob("records.*"))
|
|
2192
|
+
self.assertEqual(records_manifest(generation), final)
|
|
2193
|
+
self.assertFalse((generation / ".devlyn/extra.log").exists())
|
|
2194
|
+
|
|
2195
|
+
def test_records_generation_reuse_and_mismatch(self):
|
|
2196
|
+
self.allocate(); self.accept()
|
|
2197
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2198
|
+
self.merge_pr()
|
|
2199
|
+
saved = self.receipt.parent / "records"
|
|
2200
|
+
shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
|
|
2201
|
+
original = records_manifest(saved)
|
|
2202
|
+
(self.task / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
2203
|
+
final = records_manifest(self.task)
|
|
2204
|
+
digest = hashlib.sha256(json.dumps(final, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
|
|
2205
|
+
generation = self.receipt.parent / ("records." + digest)
|
|
2206
|
+
shutil.copytree(self.task / ".devlyn", generation / ".devlyn")
|
|
2207
|
+
extra = generation / ".devlyn/unexpected"
|
|
2208
|
+
extra.write_bytes(b"keep this copy\n")
|
|
2209
|
+
mismatch = records_manifest(generation)
|
|
2210
|
+
result, _ = self.complete(success=False)
|
|
2211
|
+
self.assertIn(generation.name, result["reason"])
|
|
2212
|
+
self.assertTrue(self.task.exists())
|
|
2213
|
+
self.assertEqual(records_manifest(generation), mismatch)
|
|
2214
|
+
self.assertEqual(records_manifest(saved), original)
|
|
2215
|
+
extra.unlink() # Owner repairs the mismatched copy before resuming.
|
|
2216
|
+
identity = generation.stat()
|
|
2217
|
+
result, _ = self.complete()
|
|
2218
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2219
|
+
self.assertFalse(self.task.exists())
|
|
2220
|
+
self.assertEqual(records_manifest(saved), original)
|
|
2221
|
+
self.assertEqual(records_manifest(generation), final)
|
|
2222
|
+
self.assertEqual(generation.stat(), identity)
|
|
2223
|
+
|
|
2224
|
+
def test_records_change_after_copy_retains_until_resume(self):
|
|
2225
|
+
from unittest.mock import patch
|
|
2226
|
+
self.allocate(); self.accept()
|
|
2227
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2228
|
+
self.merge_pr()
|
|
2229
|
+
original = records_manifest(self.task)
|
|
2230
|
+
saved = self.receipt.parent / "records"
|
|
2231
|
+
check = removable
|
|
2232
|
+
def changed(receipt, work):
|
|
2233
|
+
check(receipt, work)
|
|
2234
|
+
if saved.exists():
|
|
2235
|
+
(work / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
2236
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
2237
|
+
with patch.dict(os.environ, self.env), patch.dict(cleanup.__globals__, {"removable": changed}):
|
|
2238
|
+
with self.assertRaisesRegex(CompletionError, "changed after it was copied; stop its writer and resume"):
|
|
2239
|
+
complete(args)
|
|
2240
|
+
self.assertTrue(self.task.exists())
|
|
2241
|
+
self.assertEqual(records_manifest(saved), original)
|
|
2242
|
+
final = records_manifest(self.task)
|
|
2243
|
+
result, _ = self.complete()
|
|
2244
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2245
|
+
self.assertFalse(self.task.exists())
|
|
2246
|
+
self.assertEqual(records_manifest(saved), original)
|
|
2247
|
+
self.assertEqual(records_manifest(next(self.receipt.parent.glob("records.*"))), final)
|
|
2248
|
+
|
|
2249
|
+
def test_records_staged_extra_file_retains_without_publishing(self):
|
|
2250
|
+
from unittest.mock import patch
|
|
2251
|
+
self.allocate(); self.accept()
|
|
2252
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2253
|
+
self.merge_pr()
|
|
2254
|
+
copy = shutil.copytree
|
|
2255
|
+
def changed(source, destination, **kwargs):
|
|
2256
|
+
result = copy(source, destination, **kwargs)
|
|
2257
|
+
(destination / "unexpected").write_bytes(b"late write\n")
|
|
2258
|
+
return result
|
|
2259
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
2260
|
+
with patch.dict(os.environ, self.env), patch.object(shutil, "copytree", changed):
|
|
2261
|
+
with self.assertRaisesRegex(CompletionError, "changed while it was copied; stop its writer and resume"):
|
|
2262
|
+
complete(args)
|
|
2263
|
+
self.assertTrue(self.task.exists())
|
|
2264
|
+
self.assertFalse((self.receipt.parent / "records").exists())
|
|
2265
|
+
self.assertEqual(list(self.receipt.parent.glob(".records-*")), [])
|
|
2266
|
+
|
|
2267
|
+
def test_linked_custody_and_interrupted_removal(self):
|
|
2268
|
+
self.allocate(); self.accept()
|
|
2269
|
+
self.configure(interrupt_remove=True)
|
|
2270
|
+
result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
|
|
2271
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2272
|
+
self.assertFalse(self.task.exists())
|
|
2273
|
+
saved = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
2274
|
+
self.assertNotIn("cleanup_started", saved)
|
|
2275
|
+
recovered = self.receipt.parent / "custody" / ".devlyn/checks.txt"
|
|
2276
|
+
self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/checks.txt"]["sha256"])
|
|
2277
|
+
result, _ = self.complete("--writers-stopped", acceptance=False)
|
|
2278
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2279
|
+
self.assertEqual(self.g("show", saved["recovery_ref"]+":product"), "accepted")
|
|
2280
|
+
|
|
2281
|
+
def test_reject_invalid_policy_before_push(self):
|
|
2282
|
+
self.allocate(); self.accept()
|
|
2283
|
+
for value in ("typo", "", " auto"):
|
|
2284
|
+
self.g("config", "--local", "devlyn.completionMode", value)
|
|
2285
|
+
_, r = self.complete("--mode", "pr", success=False)
|
|
2286
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2287
|
+
result, _ = self.complete("--local-only")
|
|
2288
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
2289
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("creates", 0), 0)
|
|
2290
|
+
|
|
2291
|
+
def test_unverified_descendant_is_never_merged(self):
|
|
2292
|
+
self.allocate(); self.accept()
|
|
2293
|
+
result, _ = self.complete("--mode", "pr")
|
|
2294
|
+
self.assertEqual(result["status"], "PR")
|
|
2295
|
+
(self.task / "product").write_text("unverified", encoding="utf-8")
|
|
2296
|
+
self.g("add", "product", work=self.task)
|
|
2297
|
+
self.g("commit", "-m", "unverified", work=self.task)
|
|
2298
|
+
_, r = self.complete(success=False)
|
|
2299
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2300
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges", 0), 0)
|
|
2301
|
+
|
|
2302
|
+
def test_interrupted_external_effects(self):
|
|
2303
|
+
self.allocate(); self.accept()
|
|
2304
|
+
for effect in ("push", "create"):
|
|
2305
|
+
self.configure(**{"interrupt_"+effect: True})
|
|
2306
|
+
_, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
|
|
2307
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2308
|
+
# A merge command that fails after merging is judged by the PR state, not its exit code.
|
|
2309
|
+
self.configure(interrupt_merge=True)
|
|
2310
|
+
result, _ = self.complete("--writers-stopped")
|
|
2311
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2312
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2313
|
+
self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
|
|
2314
|
+
|
|
2315
|
+
def test_retains_dirty_locked_and_active_tree(self):
|
|
2316
|
+
self.allocate(); self.accept()
|
|
2317
|
+
for case in ("no-yield", "dirty", "untracked", "locked", "cwd"):
|
|
2318
|
+
path = self.task / ("product" if case == "dirty" else "unknown")
|
|
2319
|
+
if case in {"dirty", "untracked"}:
|
|
2320
|
+
path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
|
|
2321
|
+
if case == "locked": self.g("worktree", "lock", str(self.task))
|
|
2322
|
+
with self.subTest(case=case):
|
|
2323
|
+
_, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
|
|
2324
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2325
|
+
self.assertTrue(self.task.exists())
|
|
2326
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2327
|
+
if case == "untracked": path.unlink()
|
|
2328
|
+
if case == "dirty": path.write_text("accepted\n", encoding="utf-8")
|
|
2329
|
+
if case == "locked": self.g("worktree", "unlock", str(self.task))
|
|
2330
|
+
|
|
2331
|
+
def test_no_adoption_and_remote_head_race(self):
|
|
2332
|
+
_, r = self.cli("allocate", "--repo", self.work, "--task", "foreign", "--branch", "main", "--repository", "test/project", "--base", "main", "--worktree", self.root / "foreign", success=False)
|
|
2333
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2334
|
+
self.allocate(); self.accept()
|
|
2335
|
+
self.complete("--mode", "pr")
|
|
2336
|
+
self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "refs/heads/task/fixture", self.g("rev-parse", "main")])
|
|
2337
|
+
_, r = self.complete(success=False)
|
|
2338
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2339
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
|
|
2340
|
+
|
|
2341
|
+
def test_concurrent_completions(self):
|
|
2342
|
+
self.allocate(); self.accept(); self.configure(pending=True)
|
|
2343
|
+
args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance), "--mode", "auto"]
|
|
2344
|
+
procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8") for _ in range(2)]
|
|
2345
|
+
for proc in procs:
|
|
2346
|
+
out, err = proc.communicate(timeout=30)
|
|
2347
|
+
self.assertEqual(proc.returncode, 0, out+err)
|
|
2348
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2349
|
+
self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1,1,1))
|
|
2350
|
+
|
|
2351
|
+
def test_custody_failure_and_evidence_tamper(self):
|
|
2352
|
+
self.allocate(); self.accept()
|
|
2353
|
+
self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
|
|
2354
|
+
bad = self.receipt.parent / "custody/.devlyn/checks.txt"
|
|
2355
|
+
bad.write_text("corrupted bound copy", encoding="utf-8")
|
|
2356
|
+
_, r = self.complete("--writers-stopped", success=False)
|
|
2357
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2358
|
+
self.assertTrue(self.task.exists())
|
|
2359
|
+
self.assertEqual((self.task / ".devlyn/checks.txt").read_text(encoding="utf-8"), "actual fixture check: accepted bytes\n")
|
|
2360
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2361
|
+
shutil.copy2(self.task / ".devlyn/checks.txt", bad) # Repair only the identified corrupt fixture copy.
|
|
2362
|
+
self.complete("--mode", "pr")
|
|
2363
|
+
(self.task / ".devlyn/checks.txt").write_text("altered after acceptance", encoding="utf-8")
|
|
2364
|
+
_, r = self.complete("--mode", "auto", success=False)
|
|
2365
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2366
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
|
|
2367
|
+
|
|
2368
|
+
def test_unbound_partial_custody_is_retained_through_cleanup(self):
|
|
2369
|
+
self.allocate(); self.accept()
|
|
2370
|
+
partial = self.receipt.parent / "custody/.devlyn/checks.txt"
|
|
2371
|
+
partial.parent.mkdir(parents=True)
|
|
2372
|
+
partial.write_text("partial interrupted copy", encoding="utf-8")
|
|
2373
|
+
original = file_record(partial)
|
|
2374
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto")
|
|
2375
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2376
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "CLEAN")
|
|
2377
|
+
self.assertFalse(self.task.exists())
|
|
2378
|
+
retained = list(self.receipt.parent.glob(".custody-*"))
|
|
2379
|
+
self.assertEqual(len(retained), 1)
|
|
2380
|
+
self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
|
|
2381
|
+
self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
|
|
2382
|
+
self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
|
|
2383
|
+
|
|
2384
|
+
def test_actual_foreign_writer_and_registration(self):
|
|
2385
|
+
# H4. Prediction: a process still using the merged task's tree settles the delivery COMPLETE with the workspace
|
|
2386
|
+
# retained (CLEANUP_PENDING) and the process named, as unobservable writers do. Before: complete ended BLOCKED
|
|
2387
|
+
# "active process <pid> uses task files", so the merged delivery never settled and a drain stopped there.
|
|
2388
|
+
self.allocate(); self.accept()
|
|
2389
|
+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=self.task)
|
|
2390
|
+
try:
|
|
2391
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto")
|
|
2392
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
2393
|
+
self.assertIn("active process", result["workspace_cleanup"]["reason"])
|
|
2394
|
+
self.assertTrue(self.task.exists())
|
|
2395
|
+
finally:
|
|
2396
|
+
child.terminate(); child.wait(timeout=5)
|
|
2397
|
+
# A foreign branch in the same registered tree never inherits ownership.
|
|
2398
|
+
self.g("switch", "-c", "foreign", work=self.task)
|
|
2399
|
+
_, r = self.complete("--writers-stopped", success=False)
|
|
2400
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2401
|
+
self.assertTrue(self.task.exists())
|
|
2402
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2403
|
+
|
|
2404
|
+
def test_remote_compare_delete_race(self):
|
|
2405
|
+
self.allocate(); self.accept()
|
|
2406
|
+
race = self.g("rev-parse", "main")
|
|
2407
|
+
self.configure(remote_delete_race=True, race_sha=race)
|
|
2408
|
+
_, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
|
|
2409
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2410
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
|
|
2411
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2412
|
+
|
|
2413
|
+
def test_auto_disallowed_unknown_then_clean_merges_directly(self):
|
|
2414
|
+
self.allocate(); self.accept()
|
|
2415
|
+
self.configure(auto_allowed=False, merge_states=["UNKNOWN", "UNKNOWN", "CLEAN"])
|
|
2416
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
2417
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2418
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2419
|
+
self.assertEqual(d["merge_state_views"], ["UNKNOWN", "UNKNOWN", "CLEAN"])
|
|
2420
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
|
|
2421
|
+
"--repo", "github.com/test/project"]])
|
|
2422
|
+
self.assertEqual(d["prs"][0]["state"], "MERGED")
|
|
2423
|
+
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
2424
|
+
|
|
2425
|
+
def test_auto_disallowed_blocked_refuses_without_merge(self):
|
|
2426
|
+
self.allocate(); self.accept()
|
|
2427
|
+
for state in ("BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
|
|
2428
|
+
with self.subTest(state=state):
|
|
2429
|
+
self.configure(auto_allowed=False, merge_state=state)
|
|
2430
|
+
result, _ = self.complete("--mode", "auto")
|
|
2431
|
+
self.assertEqual(result["status"], "PR")
|
|
2432
|
+
self.assertIn("not mergeable: " + state, result["merge_refused"])
|
|
2433
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2434
|
+
self.assertEqual(d["merge_state_views"], ["BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
|
|
2435
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2436
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2437
|
+
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
2438
|
+
self.assertTrue(self.task.exists())
|
|
2439
|
+
|
|
2440
|
+
def test_auto_allowed_unknown_keeps_auto_pending(self):
|
|
2441
|
+
self.allocate(); self.accept()
|
|
2442
|
+
self.configure(auto_allowed=True, merge_state="UNKNOWN")
|
|
2443
|
+
result, _ = self.complete("--mode", "auto")
|
|
2444
|
+
self.assertEqual(result["status"], "PENDING")
|
|
2445
|
+
self.assertNotIn("merge_refused", result)
|
|
2446
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2447
|
+
self.assertEqual(d.get("merge_state_views", []), [])
|
|
2448
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha,
|
|
2449
|
+
"--repo", "github.com/test/project"]])
|
|
2450
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2451
|
+
self.assertTrue(d["prs"][0]["autoMergeRequest"])
|
|
2452
|
+
self.assertTrue(self.task.exists())
|
|
2453
|
+
self.configure(auto_setting_error=True)
|
|
2454
|
+
result, _ = self.complete()
|
|
2455
|
+
self.assertEqual(result["status"], "PENDING")
|
|
2456
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["auto_setting_reads"], 1)
|
|
2457
|
+
|
|
2458
|
+
def test_auto_disallowed_unknown_bound_refuses_without_merge(self):
|
|
2459
|
+
self.allocate(); self.accept()
|
|
2460
|
+
self.configure(auto_allowed=False, merge_state="UNKNOWN")
|
|
2461
|
+
result, _ = self.complete("--mode", "auto")
|
|
2462
|
+
self.assertEqual(result["status"], "PR")
|
|
2463
|
+
self.assertIn("not mergeable: UNKNOWN", result["merge_refused"])
|
|
2464
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2465
|
+
self.assertEqual(d["merge_state_views"], ["UNKNOWN"] * 10)
|
|
2466
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2467
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2468
|
+
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
2469
|
+
self.assertTrue(self.task.exists())
|
|
2470
|
+
|
|
2471
|
+
def test_auto_disallowed_other_immediate_states_attempt_direct_merge(self):
|
|
2472
|
+
self.allocate(); self.accept()
|
|
2473
|
+
for state in ("HAS_HOOKS", "UNSTABLE"):
|
|
2474
|
+
with self.subTest(state=state):
|
|
2475
|
+
self.configure(auto_allowed=False, merge_state=state, merge_error=True, merge_calls=[])
|
|
2476
|
+
result, _ = self.complete("--mode", "auto")
|
|
2477
|
+
self.assertEqual(result["status"], "PR")
|
|
2478
|
+
self.assertIn("merge attempt refused", result["merge_refused"])
|
|
2479
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2480
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge",
|
|
2481
|
+
"--match-head-commit", self.sha, "--repo", "github.com/test/project"]])
|
|
2482
|
+
|
|
2483
|
+
def test_auto_setting_read_failure_or_invalid_value_blocks_with_resume(self):
|
|
2484
|
+
self.allocate(); self.accept()
|
|
2485
|
+
for values in ({"auto_setting_error": True}, *({"auto_setting_error": False, "auto_setting_output": output}
|
|
2486
|
+
for output in ('null', '"true"', '"false"', '0', '1', '{}', '[]', '', 'invalid'))):
|
|
2487
|
+
with self.subTest(values=values):
|
|
2488
|
+
self.configure(**values)
|
|
2489
|
+
result, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
|
|
2490
|
+
self.assertNotEqual(process.returncode, 0)
|
|
2491
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2492
|
+
if values.get("auto_setting_output") == 'null':
|
|
2493
|
+
self.assertEqual(result["reason"], "repository autoMergeAllowed must be a JSON boolean")
|
|
2494
|
+
self.assertIn(str(self.receipt), result["resume"])
|
|
2495
|
+
self.assertNotIn("merge_refused", result)
|
|
2496
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2497
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2498
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2499
|
+
self.configure(auto_setting_output='false')
|
|
2500
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
2501
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2502
|
+
|
|
2503
|
+
def test_auto_disallowed_status_poll_failure_blocks_with_resume(self):
|
|
2504
|
+
self.allocate(); self.accept()
|
|
2505
|
+
self.configure(auto_allowed=False, merge_state_error=True)
|
|
2506
|
+
result, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
|
|
2507
|
+
self.assertNotEqual(process.returncode, 0)
|
|
2508
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2509
|
+
self.assertIn("merge state read failed", result["reason"])
|
|
2510
|
+
self.assertIn(str(self.receipt), result["resume"])
|
|
2511
|
+
self.assertNotIn("merge_refused", result)
|
|
2512
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2513
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2514
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2515
|
+
self.configure(merge_state_error=False)
|
|
2516
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
2517
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2518
|
+
|
|
2519
|
+
def test_merge_attempt_refusal_keeps_pr(self):
|
|
2520
|
+
self.allocate(); self.accept()
|
|
2521
|
+
self.configure(auto_allowed=False, merge_error=True)
|
|
2522
|
+
result, _ = self.complete("--mode", "auto")
|
|
2523
|
+
self.assertEqual(result["status"], "PR")
|
|
2524
|
+
self.assertIn("merge attempt refused", result["merge_refused"])
|
|
2525
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["merges"], 1)
|
|
2526
|
+
|
|
2527
|
+
def test_pr_mode_does_not_read_auto_setting(self):
|
|
2528
|
+
self.allocate(); self.accept()
|
|
2529
|
+
self.configure(auto_setting_error=True)
|
|
2530
|
+
result, _ = self.complete("--mode", "pr")
|
|
2531
|
+
self.assertEqual(result["status"], "PR")
|
|
2532
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("auto_setting_reads", 0), 0)
|
|
2533
|
+
|
|
2534
|
+
def test_policy_and_local_only_persist(self):
|
|
2535
|
+
self.allocate(); self.accept()
|
|
2536
|
+
self.configure(merge_allowed=False)
|
|
2537
|
+
result, _ = self.complete()
|
|
2538
|
+
self.assertEqual((result["status"], result["merge_refused"]), ("PR", "repository disallows merge commits"))
|
|
2539
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2540
|
+
self.assertEqual((d.get("pushs", 0), d.get("merges", 0)), (1, 0))
|
|
2541
|
+
self.complete("--mode", "pr")
|
|
2542
|
+
result, _ = self.complete()
|
|
2543
|
+
self.assertEqual(result["status"], "PR")
|
|
2544
|
+
self.assertNotIn("merge_refused", result)
|
|
2545
|
+
self.configure(merge_allowed=True, auto_allowed=False, pending=True)
|
|
2546
|
+
result, _ = self.complete("--mode", "auto")
|
|
2547
|
+
self.assertEqual(result["status"], "PR")
|
|
2548
|
+
self.assertIn("not mergeable: BLOCKED", result["merge_refused"])
|
|
2549
|
+
self.assertTrue(self.task.exists())
|
|
2550
|
+
# Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
|
|
2551
|
+
result, _ = self.complete("--no-push", success=False)
|
|
2552
|
+
self.assertIn("fixture already has a pushed PR https://github.com/test/project/pull/1", result["reason"])
|
|
2553
|
+
self.assertNotIn("local_only", json.loads(self.receipt.read_text(encoding="utf-8")))
|
|
2554
|
+
|
|
2555
|
+
def test_pipeline_acceptance_is_refused_with_recovery_instruction(self):
|
|
2556
|
+
# Binding an archived resolve run needed the retired helpers. Whatever completion flags come with it,
|
|
2557
|
+
# it is refused before the receipt, a ref or the worktree changes, with the 4.1.0 command that finishes
|
|
2558
|
+
# it in the requested delivery mode; it never becomes direct acceptance.
|
|
2559
|
+
self.allocate(); self.accept()
|
|
2560
|
+
self.acceptance.write_text(json.dumps({"kind": "pipeline", "task": "fixture", "source_sha": self.sha,
|
|
2561
|
+
"run_id": "fixture-run"}), encoding="utf-8")
|
|
2562
|
+
def state():
|
|
2563
|
+
return (self.receipt.read_bytes(), self.g("show-ref"), self.g("rev-parse", "HEAD", work=self.task),
|
|
2564
|
+
self.g("status", "--porcelain", "--untracked-files=all", work=self.task),
|
|
2565
|
+
sorted(p.name for p in self.receipt.parent.iterdir() if p.name != "lock"))
|
|
2566
|
+
before = state()
|
|
2567
|
+
publish, local = "binds the pipeline acceptance before publishing", "ends as LOCAL_ONLY without binding"
|
|
2568
|
+
for flags, outcome in (((), publish), (("--mode", "pr"), publish), (("--mode", "auto", "--writers-stopped"), publish),
|
|
2569
|
+
(("--local-only",), local), (("--local-only", "--writers-stopped"), local)):
|
|
2570
|
+
with self.subTest(flags=flags):
|
|
2571
|
+
result, r = self.complete(*flags, success=False)
|
|
2572
|
+
self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
|
|
2573
|
+
self.assertEqual(state(), before)
|
|
2574
|
+
self.assertIn("`npm pack devlyn-cli@4.1.0`", result["reason"])
|
|
2575
|
+
self.assertIn(shlex.join(["complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance), *flags]) + "`",
|
|
2576
|
+
result["reason"])
|
|
2577
|
+
self.assertIn(outcome, result["reason"])
|
|
2578
|
+
result, r = self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance, success=False)
|
|
2579
|
+
self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
|
|
2580
|
+
self.assertEqual(state(), before)
|
|
2581
|
+
self.assertIn(publish, result["reason"])
|
|
2582
|
+
self.assertNotIn(json.loads(before[0])["recovery_ref"], before[1])
|
|
2583
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
2584
|
+
|
|
2585
|
+
def test_bound_pipeline_receipt_resumes_delivery(self):
|
|
2586
|
+
# A receipt an earlier release bound to a resolve run keeps its custody; delivery resumes here.
|
|
2587
|
+
self.allocate(); self.accept()
|
|
2588
|
+
self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
|
|
2589
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
2590
|
+
receipt["acceptance"] = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
|
|
2591
|
+
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
2592
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto", acceptance=False)
|
|
2593
|
+
self.assertEqual((result["status"], result["acceptance"]), ("COMPLETE", "pipeline"))
|
|
2594
|
+
self.assertFalse(self.task.exists())
|
|
2595
|
+
self.assertEqual(self.g("show", receipt["recovery_ref"] + ":product"), "accepted")
|
|
2596
|
+
|
|
2597
|
+
def test_legacy_in_place_resume_does_not_overwrite_ignored_collision(self):
|
|
2598
|
+
self.allocate_legacy(); self.accept()
|
|
2599
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2600
|
+
self.merge_pr()
|
|
2601
|
+
self.g("switch", "main")
|
|
2602
|
+
user_file = self.task / "ignored/user.txt"
|
|
2603
|
+
user_file.parent.mkdir()
|
|
2604
|
+
user_file.write_text("retained user data", encoding="utf-8")
|
|
2605
|
+
before = self.anchor_state()
|
|
2606
|
+
self.configure(interrupt_delete=True)
|
|
2607
|
+
_, r = self.complete(acceptance=False, success=False)
|
|
2608
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2609
|
+
self.assertEqual(self.anchor_state(), before)
|
|
2610
|
+
other = self.root / "advance"
|
|
2611
|
+
self.run_cmd(["git", "clone", str(self.bare), str(other)])
|
|
2612
|
+
(other / "ignored").mkdir()
|
|
2613
|
+
(other / "ignored/user.txt").write_text("new upstream tracked file", encoding="utf-8")
|
|
2614
|
+
self.g("add", "-f", "ignored/user.txt", work=other)
|
|
2615
|
+
self.g("commit", "-m", "upstream collision", work=other)
|
|
2616
|
+
self.g("push", "origin", "main", work=other)
|
|
2617
|
+
result, _ = self.complete(acceptance=False)
|
|
2618
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2619
|
+
self.assertEqual(self.anchor_state(), before)
|
|
2620
|
+
self.assertEqual(user_file.read_text(encoding="utf-8"), "retained user data")
|
|
2621
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2622
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2623
|
+
|
|
2624
|
+
def test_replaced_tree_and_unsafe_custody(self):
|
|
2625
|
+
self.allocate(); self.accept()
|
|
2626
|
+
elsewhere = self.root / "elsewhere"
|
|
2627
|
+
elsewhere.mkdir()
|
|
2628
|
+
(self.receipt.parent / "custody").symlink_to(elsewhere, target_is_directory=True)
|
|
2629
|
+
_, r = self.complete(success=False)
|
|
2630
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2631
|
+
self.assertEqual(list(elsewhere.iterdir()), [])
|
|
2632
|
+
(self.receipt.parent / "custody").unlink()
|
|
2633
|
+
old = self.root / "old-tree"
|
|
2634
|
+
self.task.rename(old)
|
|
2635
|
+
shutil.copytree(old, self.task)
|
|
2636
|
+
_, r = self.complete(success=False)
|
|
2637
|
+
self.assertNotEqual(r.returncode, 0)
|
|
2638
|
+
self.assertIn("replaced", json.loads(r.stdout)["reason"])
|
|
2639
|
+
self.assertTrue(self.task.exists())
|
|
2640
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
2641
|
+
|
|
2642
|
+
def allocate_local(self, name, **source):
|
|
2643
|
+
args = ["allocate", "--repo", self.work, "--task", name, "--branch", "task/"+name, "--repository", "test/project",
|
|
2644
|
+
"--base", "main", "--worktree", self.root / name]
|
|
2645
|
+
for key, value in source.items():
|
|
2646
|
+
args += ["--" + key.replace("_", "-"), value]
|
|
2647
|
+
result, _ = self.cli(*args)
|
|
2648
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2649
|
+
return result
|
|
2650
|
+
|
|
2651
|
+
def loop_result(self, verdict="ACCEPTED"):
|
|
2652
|
+
(self.task / "product").write_text(verdict.lower() + "\n", encoding="utf-8")
|
|
2653
|
+
self.g("add", "product", work=self.task)
|
|
2654
|
+
self.g("commit", "-m", "loop source", work=self.task)
|
|
2655
|
+
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
2656
|
+
stream = self.task / ".devlyn/loop/runs/1/cmd-0.stdout"
|
|
2657
|
+
stream.parent.mkdir(parents=True, exist_ok=True)
|
|
2658
|
+
stream.write_text("check output\n", encoding="utf-8")
|
|
2659
|
+
self.acceptance = self.task / ".devlyn/loop/acceptance.json"
|
|
2660
|
+
self.acceptance.write_text(json.dumps({"kind": "loop", "task": json.loads(self.receipt.read_text())["task"], "source_sha": self.sha,
|
|
2661
|
+
"verdict": verdict, "evidence": [".devlyn/loop/runs/1/cmd-0.stdout"]}), encoding="utf-8")
|
|
2662
|
+
return self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)[0]
|
|
2663
|
+
|
|
2664
|
+
def terminal(self):
|
|
2665
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
2666
|
+
self.g("add", "queue.md", work=self.task)
|
|
2667
|
+
self.g("commit", "-m", "terminal", work=self.task)
|
|
2668
|
+
commit = self.g("rev-parse", "HEAD", work=self.task)
|
|
2669
|
+
return commit, self.cli("attach", "--receipt", self.receipt, "--commit", commit, "--file", "queue.md")[0]
|
|
2670
|
+
|
|
2671
|
+
def test_local_chain_starts_from_accepted_source_not_terminal_commit(self):
|
|
2672
|
+
base = self.g("rev-parse", "main")
|
|
2673
|
+
other = self.root / "advance" # The remote advances; a local allocation never fetches it.
|
|
2674
|
+
self.run_cmd(["git", "clone", str(self.bare), str(other)])
|
|
2675
|
+
(other / "product").write_text("remote advance\n", encoding="utf-8")
|
|
2676
|
+
self.g("commit", "-am", "remote advance", work=other)
|
|
2677
|
+
self.g("push", "origin", "main", work=other)
|
|
2678
|
+
self.configure(pushs=0)
|
|
2679
|
+
result = self.allocate_local("first", local_base=base)
|
|
2680
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2681
|
+
self.assertEqual((result["reconciled"], receipt["baseline"], receipt["local_only"]), ([], base, True))
|
|
2682
|
+
self.assertNotEqual(self.run_cmd(["git", "-C", str(self.work), "rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"], success=False).returncode, 0)
|
|
2683
|
+
self.assertEqual(self.loop_result()["status"], "ACCEPTED")
|
|
2684
|
+
source = self.sha
|
|
2685
|
+
terminal, _ = self.terminal()
|
|
2686
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2687
|
+
self.assertEqual((receipt["source_sha"], receipt["publish_sha"], self.g("rev-parse", receipt["recovery_ref"])), (source, terminal, terminal))
|
|
2688
|
+
first = self.receipt
|
|
2689
|
+
self.allocate_local("second", from_receipt=first)
|
|
2690
|
+
second = json.loads(self.receipt.read_text())
|
|
2691
|
+
self.assertEqual((second["baseline"], second["allocated_from"]["source_sha"], self.g("rev-parse", "HEAD", work=self.task)), (source, source, source))
|
|
2692
|
+
self.receipt = first
|
|
2693
|
+
result, _ = self.complete("--mode", "auto", acceptance=False)
|
|
2694
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
2695
|
+
self.assertEqual(json.loads(self.data.read_text()).get("pushs", 0), 0)
|
|
2696
|
+
|
|
2697
|
+
def test_local_allocation_refuses_unaccepted_failed_or_inexact_sources(self):
|
|
2698
|
+
self.allocate_local("pending", local_base=self.g("rev-parse", "main"))
|
|
2699
|
+
predecessor = self.receipt
|
|
2700
|
+
args = ["allocate", "--repo", self.work, "--task", "next", "--branch", "task/next", "--repository", "test/project",
|
|
2701
|
+
"--base", "main", "--worktree", self.root / "next"]
|
|
2702
|
+
for state in ("pending", "failed"):
|
|
2703
|
+
if state == "failed":
|
|
2704
|
+
self.assertEqual(self.loop_result("FAILED")["status"], "FAILED")
|
|
2705
|
+
result, _ = self.cli(*args, "--from-receipt", predecessor, success=False)
|
|
2706
|
+
self.assertIn("predecessor has no accepted result", result["reason"])
|
|
2707
|
+
result, _ = self.cli(*args, "--local-base", "main", success=False)
|
|
2708
|
+
self.assertIn("exact local commit", result["reason"])
|
|
2709
|
+
self.assertEqual(self.g("branch", "--list", "task/next"), "")
|
|
2710
|
+
self.assertFalse((self.root / "next").exists())
|
|
2711
|
+
|
|
2712
|
+
def test_local_allocation_needs_no_repository(self):
|
|
2713
|
+
"""E4. Prediction: without --repository, a remote allocation is refused naming it, with nothing created, while a
|
|
2714
|
+
local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
|
|
2715
|
+
LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
|
|
2716
|
+
args = ["allocate", "--repo", self.work, "--base", "main"]
|
|
2717
|
+
refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
|
|
2718
|
+
self.assertIn("--repository", refused["reason"])
|
|
2719
|
+
self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
|
|
2720
|
+
self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
|
|
2721
|
+
result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
|
|
2722
|
+
"--local-base", self.g("rev-parse", "HEAD"))
|
|
2723
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2724
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2725
|
+
self.accept()
|
|
2726
|
+
self.assertEqual(self.complete("--local-only")[0]["status"], "LOCAL_ONLY")
|
|
2727
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
2728
|
+
|
|
2729
|
+
def test_local_allocation_needs_no_base(self):
|
|
2730
|
+
"""P4-2. Prediction: a local allocation with --local-base and no --base, the form agents wrote in 3 of 3 timed
|
|
2731
|
+
runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
|
|
2732
|
+
nothing created."""
|
|
2733
|
+
args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
|
|
2734
|
+
refused, _ = self.cli(*args, "--repository", "test/project", success=False)
|
|
2735
|
+
self.assertIn("--base", refused["reason"])
|
|
2736
|
+
self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
|
|
2737
|
+
result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
|
|
2738
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2739
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2740
|
+
self.accept()
|
|
2741
|
+
self.assertEqual(self.complete("--local-only")[0]["status"], "LOCAL_ONLY")
|
|
2742
|
+
|
|
2743
|
+
def test_allocation_canonicalizes_symlinked_and_dotted_worktree_paths(self):
|
|
2744
|
+
"""P4-2. Prediction: an absent worktree path reached through a symlinked directory (macOS /tmp) or written with
|
|
2745
|
+
`..` (`../work-x`, `$(pwd -P)/../work-x`) is ALLOCATED at its canonical path, and that path, now present, is
|
|
2746
|
+
refused through the link as not absent."""
|
|
2747
|
+
alias = self.root / "alias"
|
|
2748
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
2749
|
+
args = ["allocate", "--repo", self.work, "--base", "main", "--local-base", self.g("rev-parse", "HEAD")]
|
|
2750
|
+
for name, given in (("linked", alias / "linked"), ("dotted", Path("..") / "dotted")):
|
|
2751
|
+
result, _ = self.cli(*args, "--task", name, "--branch", "task/" + name, "--worktree", given, cwd=self.work)
|
|
2752
|
+
self.assertEqual((result["status"], result["worktree"]), ("ALLOCATED", str(self.root / name)))
|
|
2753
|
+
self.assertEqual(self.g("rev-parse", "--show-toplevel", work=self.root / name), str(self.root / name))
|
|
2754
|
+
refused, _ = self.cli(*args, "--task", "again", "--branch", "task/again", "--worktree", alias / "dotted", success=False)
|
|
2755
|
+
self.assertIn("linked worktree must be an absent path", refused["reason"])
|
|
2756
|
+
|
|
2757
|
+
def test_completion_accepts_the_path_form_the_worktree_was_allocated_with(self):
|
|
2758
|
+
"""Prediction: an acceptance named through the symlinked or `..` form the worktree was allocated with completes
|
|
2759
|
+
LOCAL_ONLY; before, allocate canonicalized the worktree but complete refused that form as outside it."""
|
|
2760
|
+
alias = self.root / "alias"
|
|
2761
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
2762
|
+
for name, given in (("linked", alias / "linked"), ("dotted", Path("..") / "dotted")):
|
|
2763
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/" + name,
|
|
2764
|
+
"--worktree", given, "--local-base", self.g("rev-parse", "HEAD"), cwd=self.work)
|
|
2765
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2766
|
+
self.accept()
|
|
2767
|
+
self.acceptance = given / ".devlyn" / "acceptance.json"
|
|
2768
|
+
self.assertEqual(self.complete("--local-only", cwd=self.work)[0]["status"], "LOCAL_ONLY")
|
|
2769
|
+
|
|
2770
|
+
def test_remote_allocation_starts_from_an_exact_commit(self):
|
|
2771
|
+
# An ideate auto/pr task starts from the exact refreshed remote base the drain checked, never a second fetch.
|
|
2772
|
+
(self.work / "product").write_text("added locally\n", encoding="utf-8")
|
|
2773
|
+
self.g("commit", "-qam", "devlyn loop: add fixture")
|
|
2774
|
+
start = self.g("rev-parse", "HEAD")
|
|
2775
|
+
args = ["allocate", "--repo", self.work, "--task", "first", "--branch", "task/first", "--repository", "test/project",
|
|
2776
|
+
"--base", "main", "--worktree", self.root / "first"]
|
|
2777
|
+
for extra, reason in ((["--start", "main"], "--start must name an exact local commit"),
|
|
2778
|
+
(["--start", start, "--local-base", start], "use one of --local-base, --from-receipt or --start")):
|
|
2779
|
+
self.assertIn(reason, self.cli(*args, *extra, success=False)[0]["reason"])
|
|
2780
|
+
result, _ = self.cli(*args, "--start", start)
|
|
2781
|
+
receipt = json.loads(Path(result["receipt"]).read_text(encoding="utf-8"))
|
|
2782
|
+
self.assertEqual((receipt["baseline"], receipt.get("local_only"), self.g("rev-parse", "HEAD", work=Path(result["worktree"]))),
|
|
2783
|
+
(start, None, start))
|
|
2784
|
+
|
|
2785
|
+
def test_failed_loop_result_keeps_custody_and_recovery_but_never_publishes(self):
|
|
2786
|
+
self.allocate()
|
|
2787
|
+
self.assertEqual(self.loop_result("FAILED")["status"], "FAILED")
|
|
2788
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2789
|
+
self.assertEqual((receipt["product"], self.g("rev-parse", receipt["recovery_ref"])), ("FAILED", self.sha))
|
|
2790
|
+
self.assertTrue((self.receipt.parent / "custody/.devlyn/loop/runs/1/cmd-0.stdout").is_file())
|
|
2791
|
+
terminal, _ = self.terminal()
|
|
2792
|
+
self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), terminal)
|
|
2793
|
+
result, _ = self.complete("--mode", "auto", acceptance=False)
|
|
2794
|
+
self.assertEqual(result["status"], "FAILED")
|
|
2795
|
+
d = json.loads(self.data.read_text())
|
|
2796
|
+
self.assertEqual((d.get("pushs", 0), d.get("creates", 0)), (0, 0))
|
|
2797
|
+
|
|
2798
|
+
def test_bind_requires_the_source_checkout_only_for_a_publishable_result(self):
|
|
2799
|
+
for verdict in ("ACCEPTED", "FAILED"):
|
|
2800
|
+
with self.subTest(verdict=verdict):
|
|
2801
|
+
self.allocate(verdict.lower() + "-detached")
|
|
2802
|
+
(self.task / "product").write_text("source\n", encoding="utf-8")
|
|
2803
|
+
self.g("add", "product", work=self.task)
|
|
2804
|
+
self.g("commit", "-m", "loop source", work=self.task)
|
|
2805
|
+
sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
2806
|
+
self.g("checkout", "-q", "--detach", "HEAD~1", work=self.task) # The executor left HEAD at another commit.
|
|
2807
|
+
acceptance = self.task / ".devlyn/loop/acceptance.json"
|
|
2808
|
+
acceptance.parent.mkdir(parents=True, exist_ok=True)
|
|
2809
|
+
(self.task / ".devlyn/loop/out.txt").write_text("check output\n", encoding="utf-8")
|
|
2810
|
+
acceptance.write_text(json.dumps({"kind": "loop", "task": json.loads(self.receipt.read_text())["task"], "source_sha": sha,
|
|
2811
|
+
"verdict": verdict, "evidence": [".devlyn/loop/out.txt"]}), encoding="utf-8")
|
|
2812
|
+
result, _ = self.cli("accept", "--receipt", self.receipt, "--acceptance", acceptance, success=False)
|
|
2813
|
+
self.assertEqual(result["status"], "FAILED" if verdict == "FAILED" else "BLOCKED", result)
|
|
2814
|
+
|
|
2815
|
+
def test_attach_requires_an_explicit_queue_file(self):
|
|
2816
|
+
result = self.run_cmd([sys.executable, str(Path(__file__).resolve()), 'attach',
|
|
2817
|
+
'--receipt', str(self.root / 'absent.json'), '--commit', 'HEAD'], success=False)
|
|
2818
|
+
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
|
2819
|
+
self.assertIn('the following arguments are required: --file', result.stderr)
|
|
2820
|
+
|
|
2821
|
+
def test_attach_requires_the_terminal_checkout_only_for_a_publishable_result(self):
|
|
2822
|
+
for verdict in ("ACCEPTED", "FAILED"):
|
|
2823
|
+
with self.subTest(verdict=verdict):
|
|
2824
|
+
self.allocate(verdict.lower())
|
|
2825
|
+
self.loop_result(verdict)
|
|
2826
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
2827
|
+
self.g("add", "queue.md", work=self.task)
|
|
2828
|
+
self.g("commit", "-m", "terminal", work=self.task)
|
|
2829
|
+
terminal = self.g("rev-parse", "HEAD", work=self.task)
|
|
2830
|
+
self.g("checkout", "-q", "--detach", self.sha, work=self.task) # The task ref is terminal; HEAD stays at the source.
|
|
2831
|
+
result, _ = self.cli("attach", "--receipt", self.receipt, "--commit", terminal, "--file", "queue.md", success=False)
|
|
2832
|
+
self.assertEqual(result["status"], "ATTACHED" if verdict == "FAILED" else "BLOCKED", result)
|
|
2833
|
+
|
|
2834
|
+
def test_local_only_completion_binds_acceptance_custody(self):
|
|
2835
|
+
self.allocate(); self.accept()
|
|
2836
|
+
result, _ = self.complete("--local-only")
|
|
2837
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
2838
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2839
|
+
self.assertEqual((receipt["source_sha"], self.g("rev-parse", receipt["recovery_ref"])), (self.sha, self.sha))
|
|
2840
|
+
verify_files(self.receipt.parent / "custody", receipt["files"])
|
|
2841
|
+
|
|
2842
|
+
def test_attach_binds_only_a_queue_only_commit_and_publishes_it_with_the_source(self):
|
|
2843
|
+
self.allocate(); self.loop_result()
|
|
2844
|
+
source = self.sha
|
|
2845
|
+
(self.task / "product").write_text("unaccepted\n", encoding="utf-8")
|
|
2846
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
2847
|
+
self.g("add", "product", "queue.md", work=self.task)
|
|
2848
|
+
self.g("commit", "-m", "mixed", work=self.task)
|
|
2849
|
+
result, _ = self.cli("attach", "--receipt", self.receipt, "--commit", self.g("rev-parse", "HEAD", work=self.task),
|
|
2850
|
+
"--file", "queue.md", success=False)
|
|
2851
|
+
self.assertIn("exactly the declared queue file", result["reason"])
|
|
2852
|
+
self.g("reset", "--hard", source, work=self.task)
|
|
2853
|
+
terminal, attached = self.terminal()
|
|
2854
|
+
self.assertEqual(attached["status"], "ATTACHED")
|
|
2855
|
+
self.assertEqual(self.cli("attach", "--receipt", self.receipt, "--commit", terminal, "--file", "queue.md")[0]["status"], "ATTACHED")
|
|
2856
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2857
|
+
self.assertEqual((receipt["source_sha"], receipt["publish_sha"]), (source, terminal))
|
|
2858
|
+
result, _ = self.complete("--mode", "pr", acceptance=False)
|
|
2859
|
+
self.assertEqual(result["status"], "PR")
|
|
2860
|
+
self.assertEqual(json.loads(self.data.read_text())["prs"][0]["headRefOid"], terminal)
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
if __name__ == "__main__":
|
|
2864
|
+
runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
2865
|
+
sys.exit(main())
|