@ikon85/agent-workflow-kit 0.36.5 → 0.38.0
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/.agents/skills/orchestrate-wave/SKILL.md +20 -12
- package/.agents/skills/setup-workflow/SKILL.md +24 -3
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.agents/skills/wrapup/SKILL.md +42 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
- package/.claude/skills/setup-workflow/SKILL.md +24 -3
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +33 -10
- package/README.md +88 -1
- package/agent-workflow-kit.package.json +36 -28
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +44 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_worktree_wrapup_contract.py +1004 -3
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +126 -4
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +143 -5
- package/scripts/worktree-lifecycle/core.py +1383 -20
- package/scripts/worktree-lifecycle/profile.py +40 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +650 -27
- package/src/cli.mjs +60 -27
- package/src/lib/bundle.mjs +1 -0
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +3 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
5
7
|
import re
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
from
|
|
8
|
+
import stat
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from fnmatch import fnmatchcase
|
|
12
|
+
from hashlib import sha256
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from time import time
|
|
15
|
+
from typing import Any, Callable
|
|
9
16
|
|
|
10
17
|
from profile import (
|
|
11
18
|
LifecycleError,
|
|
12
19
|
WorktreeProfile,
|
|
13
20
|
load_profile,
|
|
21
|
+
load_profile_text,
|
|
14
22
|
local_branch_exists,
|
|
15
23
|
main_worktree,
|
|
16
24
|
registered_worktrees,
|
|
@@ -19,6 +27,94 @@ from profile import (
|
|
|
19
27
|
|
|
20
28
|
_BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:merge|checkout))\b")
|
|
21
29
|
_BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
|
|
30
|
+
ARTIFACT_BASELINE_FILE = "awkit-artifact-baseline-v1.json"
|
|
31
|
+
LANDING_ATTEMPT_FILE = "awkit-landing-attempt-v1.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaselineBackfillDeferred(LifecycleError):
|
|
35
|
+
"""A safe legacy baseline cannot be captured until consumer state changes."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def durable_atomic_json(
|
|
39
|
+
path: Path,
|
|
40
|
+
document: dict[str, Any],
|
|
41
|
+
*,
|
|
42
|
+
label: str,
|
|
43
|
+
mode: int = 0o666,
|
|
44
|
+
sort_keys: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Atomically replace one JSON journal and durably publish its directory entry."""
|
|
47
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
48
|
+
try:
|
|
49
|
+
descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
|
|
50
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
51
|
+
json.dump(
|
|
52
|
+
document,
|
|
53
|
+
handle,
|
|
54
|
+
ensure_ascii=False,
|
|
55
|
+
indent=2,
|
|
56
|
+
sort_keys=sort_keys,
|
|
57
|
+
)
|
|
58
|
+
handle.write("\n")
|
|
59
|
+
handle.flush()
|
|
60
|
+
os.fsync(handle.fileno())
|
|
61
|
+
os.replace(temporary, path)
|
|
62
|
+
directory_descriptor = os.open(
|
|
63
|
+
path.parent,
|
|
64
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
os.fsync(directory_descriptor)
|
|
68
|
+
finally:
|
|
69
|
+
os.close(directory_descriptor)
|
|
70
|
+
except OSError as error:
|
|
71
|
+
temporary.unlink(missing_ok=True)
|
|
72
|
+
raise LifecycleError(f"cannot {label}: {error}") from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def durable_replace(source: Path, destination: Path, *, label: str) -> None:
|
|
76
|
+
"""Rename one journal durably without inspecting or claiming its payload files."""
|
|
77
|
+
try:
|
|
78
|
+
os.replace(source, destination)
|
|
79
|
+
directory_descriptor = os.open(
|
|
80
|
+
destination.parent,
|
|
81
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
os.fsync(directory_descriptor)
|
|
85
|
+
finally:
|
|
86
|
+
os.close(directory_descriptor)
|
|
87
|
+
except OSError as error:
|
|
88
|
+
raise LifecycleError(f"cannot {label}: {error}") from error
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def path_glob_matches(path: str, pattern: str) -> bool:
|
|
92
|
+
"""Match POSIX path segments while retaining fnmatch bracket compatibility."""
|
|
93
|
+
path_parts = PurePosixPath(path).parts
|
|
94
|
+
pattern_parts = PurePosixPath(pattern).parts
|
|
95
|
+
memo: dict[tuple[int, int], bool] = {}
|
|
96
|
+
|
|
97
|
+
def matches(path_index: int, pattern_index: int) -> bool:
|
|
98
|
+
key = (path_index, pattern_index)
|
|
99
|
+
if key in memo:
|
|
100
|
+
return memo[key]
|
|
101
|
+
if pattern_index == len(pattern_parts):
|
|
102
|
+
result = path_index == len(path_parts)
|
|
103
|
+
elif pattern_parts[pattern_index] == "**":
|
|
104
|
+
result = matches(path_index, pattern_index + 1) or (
|
|
105
|
+
path_index < len(path_parts)
|
|
106
|
+
and matches(path_index + 1, pattern_index)
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
result = (
|
|
110
|
+
path_index < len(path_parts)
|
|
111
|
+
and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index])
|
|
112
|
+
and matches(path_index + 1, pattern_index + 1)
|
|
113
|
+
)
|
|
114
|
+
memo[key] = result
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
return matches(0, 0)
|
|
22
118
|
|
|
23
119
|
@dataclass(frozen=True)
|
|
24
120
|
class RepoFacts:
|
|
@@ -44,12 +140,591 @@ class CleanupAssessment:
|
|
|
44
140
|
branch: str
|
|
45
141
|
assumptions: str
|
|
46
142
|
reasons: tuple[str, ...]
|
|
143
|
+
root_device: int
|
|
144
|
+
root_inode: int
|
|
145
|
+
scratch_files: tuple[str, ...] = ()
|
|
146
|
+
scratch_evidence: tuple[dict[str, Any], ...] = ()
|
|
47
147
|
|
|
48
148
|
@property
|
|
49
149
|
def removable(self) -> bool:
|
|
50
150
|
return not self.reasons
|
|
51
151
|
|
|
52
152
|
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class CleanupFacts:
|
|
155
|
+
worktree: Path
|
|
156
|
+
branch: str
|
|
157
|
+
registered: bool
|
|
158
|
+
is_main: bool
|
|
159
|
+
tracked_files: tuple[str, ...]
|
|
160
|
+
untracked_files: tuple[str, ...]
|
|
161
|
+
merged: bool
|
|
162
|
+
pr_state: str
|
|
163
|
+
assumptions: str
|
|
164
|
+
root_device: int
|
|
165
|
+
root_inode: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class ArtifactBaseline:
|
|
170
|
+
worktree: Path
|
|
171
|
+
branch: str
|
|
172
|
+
root_device: int
|
|
173
|
+
root_inode: int
|
|
174
|
+
setup_head: str
|
|
175
|
+
initial_ignored_files: tuple[str, ...]
|
|
176
|
+
initial_untracked_files: tuple[str, ...]
|
|
177
|
+
digest: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ignored_file_inventory(worktree: Path) -> tuple[str, ...]:
|
|
181
|
+
result = run(
|
|
182
|
+
[
|
|
183
|
+
"git", "ls-files", "--others", "--ignored",
|
|
184
|
+
"--exclude-standard", "-z",
|
|
185
|
+
],
|
|
186
|
+
cwd=worktree,
|
|
187
|
+
)
|
|
188
|
+
return tuple(sorted(path for path in result.stdout.split("\0") if path))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def untracked_file_inventory(worktree: Path) -> tuple[str, ...]:
|
|
192
|
+
ordinary = run(
|
|
193
|
+
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
194
|
+
cwd=worktree,
|
|
195
|
+
).stdout.split("\0")
|
|
196
|
+
return tuple(sorted(
|
|
197
|
+
set(path for path in ordinary if path).union(ignored_file_inventory(worktree))
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def artifact_baseline_path(worktree: Path) -> Path:
|
|
202
|
+
result = run(
|
|
203
|
+
["git", "rev-parse", "--absolute-git-dir"],
|
|
204
|
+
cwd=worktree,
|
|
205
|
+
)
|
|
206
|
+
git_dir = Path(result.stdout.strip())
|
|
207
|
+
if not git_dir.is_absolute():
|
|
208
|
+
raise LifecycleError("artifact provenance baseline git dir is not absolute")
|
|
209
|
+
return git_dir / ARTIFACT_BASELINE_FILE
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _baseline_payload(
|
|
213
|
+
*,
|
|
214
|
+
worktree: Path,
|
|
215
|
+
branch: str,
|
|
216
|
+
root_device: int,
|
|
217
|
+
root_inode: int,
|
|
218
|
+
setup_head: str,
|
|
219
|
+
initial_ignored_files: tuple[str, ...],
|
|
220
|
+
initial_untracked_files: tuple[str, ...],
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
return {
|
|
223
|
+
"contractVersion": 2,
|
|
224
|
+
"worktree": str(worktree),
|
|
225
|
+
"branch": branch,
|
|
226
|
+
"rootDevice": root_device,
|
|
227
|
+
"rootInode": root_inode,
|
|
228
|
+
"setupHead": setup_head,
|
|
229
|
+
"initialIgnoredFiles": list(initial_ignored_files),
|
|
230
|
+
"initialUntrackedFiles": list(initial_untracked_files),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _baseline_digest(payload: dict[str, Any]) -> str:
|
|
235
|
+
encoded = json.dumps(
|
|
236
|
+
payload,
|
|
237
|
+
ensure_ascii=False,
|
|
238
|
+
separators=(",", ":"),
|
|
239
|
+
sort_keys=True,
|
|
240
|
+
).encode("utf-8")
|
|
241
|
+
return sha256(encoded).hexdigest()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def landing_cleanup_policy_digest(profile: WorktreeProfile) -> str:
|
|
245
|
+
"""Bind one attempt to the exact ordered policy that nominated its files."""
|
|
246
|
+
return _baseline_digest({
|
|
247
|
+
"scratchPatterns": list(profile.scratch_patterns),
|
|
248
|
+
"landingGeneratedArtifactPatterns": list(
|
|
249
|
+
profile.landing_generated_artifact_patterns
|
|
250
|
+
),
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def capture_artifact_baseline(
|
|
255
|
+
worktree: Path,
|
|
256
|
+
*,
|
|
257
|
+
reject_ignored_patterns: tuple[str, ...] = (),
|
|
258
|
+
) -> ArtifactBaseline:
|
|
259
|
+
worktree = worktree.resolve()
|
|
260
|
+
metadata = worktree.stat()
|
|
261
|
+
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
262
|
+
setup_head = run(["git", "rev-parse", "HEAD"], cwd=worktree).stdout.strip()
|
|
263
|
+
ignored = ignored_file_inventory(worktree)
|
|
264
|
+
untracked = untracked_file_inventory(worktree)
|
|
265
|
+
blocked = tuple(sorted(
|
|
266
|
+
path
|
|
267
|
+
for path in ignored
|
|
268
|
+
if any(path_glob_matches(path, pattern) for pattern in reject_ignored_patterns)
|
|
269
|
+
))
|
|
270
|
+
if blocked:
|
|
271
|
+
raise BaselineBackfillDeferred(
|
|
272
|
+
"landing-start generated paths are consumer-owned and protected: "
|
|
273
|
+
+ ", ".join(blocked)
|
|
274
|
+
)
|
|
275
|
+
payload = _baseline_payload(
|
|
276
|
+
worktree=worktree,
|
|
277
|
+
branch=branch,
|
|
278
|
+
root_device=metadata.st_dev,
|
|
279
|
+
root_inode=metadata.st_ino,
|
|
280
|
+
setup_head=setup_head,
|
|
281
|
+
initial_ignored_files=ignored,
|
|
282
|
+
initial_untracked_files=untracked,
|
|
283
|
+
)
|
|
284
|
+
digest = _baseline_digest(payload)
|
|
285
|
+
path = artifact_baseline_path(worktree)
|
|
286
|
+
durable_atomic_json(
|
|
287
|
+
path,
|
|
288
|
+
{**payload, "sha256": digest},
|
|
289
|
+
label="write artifact provenance baseline",
|
|
290
|
+
)
|
|
291
|
+
return ArtifactBaseline(
|
|
292
|
+
worktree,
|
|
293
|
+
branch,
|
|
294
|
+
metadata.st_dev,
|
|
295
|
+
metadata.st_ino,
|
|
296
|
+
setup_head,
|
|
297
|
+
ignored,
|
|
298
|
+
untracked,
|
|
299
|
+
digest,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_artifact_baseline(worktree: Path) -> ArtifactBaseline:
|
|
304
|
+
worktree = worktree.resolve()
|
|
305
|
+
path = artifact_baseline_path(worktree)
|
|
306
|
+
try:
|
|
307
|
+
if path.is_symlink() or not path.is_file():
|
|
308
|
+
raise LifecycleError("artifact provenance baseline is missing or not a regular file")
|
|
309
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
310
|
+
payload = {
|
|
311
|
+
key: document[key]
|
|
312
|
+
for key in (
|
|
313
|
+
"contractVersion",
|
|
314
|
+
"worktree",
|
|
315
|
+
"branch",
|
|
316
|
+
"rootDevice",
|
|
317
|
+
"rootInode",
|
|
318
|
+
"setupHead",
|
|
319
|
+
"initialIgnoredFiles",
|
|
320
|
+
"initialUntrackedFiles",
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
digest = document["sha256"]
|
|
324
|
+
except LifecycleError:
|
|
325
|
+
raise
|
|
326
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
327
|
+
raise LifecycleError(f"artifact provenance baseline is incoherent: {error}") from error
|
|
328
|
+
ignored = payload["initialIgnoredFiles"]
|
|
329
|
+
untracked = payload["initialUntrackedFiles"]
|
|
330
|
+
if (
|
|
331
|
+
payload["contractVersion"] != 2
|
|
332
|
+
or not isinstance(payload["worktree"], str)
|
|
333
|
+
or not isinstance(payload["branch"], str)
|
|
334
|
+
or not payload["branch"]
|
|
335
|
+
or type(payload["rootDevice"]) is not int
|
|
336
|
+
or type(payload["rootInode"]) is not int
|
|
337
|
+
or not isinstance(payload["setupHead"], str)
|
|
338
|
+
or re.fullmatch(r"[0-9a-f]{40,64}", payload["setupHead"]) is None
|
|
339
|
+
or not isinstance(ignored, list)
|
|
340
|
+
or not all(
|
|
341
|
+
isinstance(path_value, str)
|
|
342
|
+
and path_value
|
|
343
|
+
and not PurePosixPath(path_value).is_absolute()
|
|
344
|
+
and ".." not in PurePosixPath(path_value).parts
|
|
345
|
+
for path_value in ignored
|
|
346
|
+
)
|
|
347
|
+
or ignored != sorted(set(ignored))
|
|
348
|
+
or not isinstance(untracked, list)
|
|
349
|
+
or not all(
|
|
350
|
+
isinstance(path_value, str)
|
|
351
|
+
and path_value
|
|
352
|
+
and not PurePosixPath(path_value).is_absolute()
|
|
353
|
+
and ".." not in PurePosixPath(path_value).parts
|
|
354
|
+
for path_value in untracked
|
|
355
|
+
)
|
|
356
|
+
or untracked != sorted(set(untracked))
|
|
357
|
+
or not set(ignored).issubset(untracked)
|
|
358
|
+
or not isinstance(digest, str)
|
|
359
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
360
|
+
or digest != _baseline_digest(payload)
|
|
361
|
+
):
|
|
362
|
+
raise LifecycleError("artifact provenance baseline is incoherent")
|
|
363
|
+
try:
|
|
364
|
+
metadata = worktree.stat()
|
|
365
|
+
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
366
|
+
except (OSError, LifecycleError) as error:
|
|
367
|
+
raise LifecycleError(
|
|
368
|
+
f"artifact provenance baseline binding cannot be verified: {error}"
|
|
369
|
+
) from error
|
|
370
|
+
if (
|
|
371
|
+
payload["worktree"] != str(worktree)
|
|
372
|
+
or payload["branch"] != branch
|
|
373
|
+
or (payload["rootDevice"], payload["rootInode"])
|
|
374
|
+
!= (metadata.st_dev, metadata.st_ino)
|
|
375
|
+
):
|
|
376
|
+
raise LifecycleError("artifact provenance baseline binding does not match worktree")
|
|
377
|
+
return ArtifactBaseline(
|
|
378
|
+
worktree,
|
|
379
|
+
branch,
|
|
380
|
+
metadata.st_dev,
|
|
381
|
+
metadata.st_ino,
|
|
382
|
+
payload["setupHead"],
|
|
383
|
+
tuple(ignored),
|
|
384
|
+
tuple(untracked),
|
|
385
|
+
digest,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def ensure_artifact_baseline(
|
|
390
|
+
worktree: Path,
|
|
391
|
+
*,
|
|
392
|
+
reject_ignored_patterns: tuple[str, ...] = (),
|
|
393
|
+
) -> ArtifactBaseline:
|
|
394
|
+
"""Load provenance or conservatively backfill one exact clean legacy worktree."""
|
|
395
|
+
path = artifact_baseline_path(worktree)
|
|
396
|
+
if os.path.lexists(path):
|
|
397
|
+
return load_artifact_baseline(worktree)
|
|
398
|
+
attempt_path = path.with_name(LANDING_ATTEMPT_FILE)
|
|
399
|
+
if os.path.lexists(attempt_path):
|
|
400
|
+
raise LifecycleError(
|
|
401
|
+
"artifact provenance baseline is missing while a landing attempt exists"
|
|
402
|
+
)
|
|
403
|
+
absolute = worktree.absolute()
|
|
404
|
+
try:
|
|
405
|
+
metadata = os.lstat(absolute)
|
|
406
|
+
except OSError as error:
|
|
407
|
+
raise LifecycleError(
|
|
408
|
+
f"legacy artifact baseline root cannot be inspected: {error}"
|
|
409
|
+
) from error
|
|
410
|
+
if not stat.S_ISDIR(metadata.st_mode) or absolute != worktree.resolve():
|
|
411
|
+
raise LifecycleError("legacy artifact baseline root is not an exact nofollow directory")
|
|
412
|
+
main = main_worktree(worktree)
|
|
413
|
+
if worktree.resolve() not in registered_worktrees(main):
|
|
414
|
+
raise LifecycleError("legacy artifact baseline requires an exact registered worktree")
|
|
415
|
+
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
416
|
+
if not branch:
|
|
417
|
+
raise LifecycleError("legacy artifact baseline requires an attached branch")
|
|
418
|
+
for command in (
|
|
419
|
+
["git", "diff", "--quiet"],
|
|
420
|
+
["git", "diff", "--cached", "--quiet"],
|
|
421
|
+
):
|
|
422
|
+
if run(command, cwd=worktree, check=False).returncode != 0:
|
|
423
|
+
raise BaselineBackfillDeferred(
|
|
424
|
+
"legacy artifact baseline requires a clean tracked worktree and index"
|
|
425
|
+
)
|
|
426
|
+
return capture_artifact_baseline(
|
|
427
|
+
worktree,
|
|
428
|
+
reject_ignored_patterns=reject_ignored_patterns,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def verified_landing_scratch_files(
|
|
433
|
+
profile: WorktreeProfile,
|
|
434
|
+
worktree: Path,
|
|
435
|
+
*,
|
|
436
|
+
expected_baseline_digest: str | None = None,
|
|
437
|
+
landing_start_files: tuple[str, ...] = (),
|
|
438
|
+
) -> tuple[str, ...]:
|
|
439
|
+
return tuple(
|
|
440
|
+
item["path"]
|
|
441
|
+
for item in verified_landing_scratch_evidence(
|
|
442
|
+
profile,
|
|
443
|
+
worktree,
|
|
444
|
+
expected_baseline_digest=expected_baseline_digest,
|
|
445
|
+
landing_start_files=landing_start_files,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def landing_start_artifact_inventory(
|
|
451
|
+
profile: WorktreeProfile,
|
|
452
|
+
worktree: Path,
|
|
453
|
+
) -> dict[str, Any]:
|
|
454
|
+
"""Persist/reuse the generated-path inventory preceding the landing build."""
|
|
455
|
+
baseline = ensure_artifact_baseline(
|
|
456
|
+
worktree,
|
|
457
|
+
reject_ignored_patterns=profile.landing_generated_artifact_patterns,
|
|
458
|
+
)
|
|
459
|
+
path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
|
|
460
|
+
if path.exists():
|
|
461
|
+
try:
|
|
462
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
463
|
+
payload = {
|
|
464
|
+
key: document[key]
|
|
465
|
+
for key in (
|
|
466
|
+
"contractVersion", "worktree", "branch", "rootDevice",
|
|
467
|
+
"rootInode", "baselineDigest", "generatedFiles",
|
|
468
|
+
"generatedEvidence", "state", "authorizedEvidence",
|
|
469
|
+
"pushSucceeded", "policyDigest",
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
digest = document["sha256"]
|
|
473
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
474
|
+
raise LifecycleError(
|
|
475
|
+
f"landing-attempt provenance is incoherent: {error}"
|
|
476
|
+
) from error
|
|
477
|
+
if payload["policyDigest"] != landing_cleanup_policy_digest(profile):
|
|
478
|
+
raise LifecycleError(
|
|
479
|
+
"landing cleanup policy changed after attempt start; "
|
|
480
|
+
"abandon the unfinished attempt before retrying"
|
|
481
|
+
)
|
|
482
|
+
if (
|
|
483
|
+
payload["contractVersion"] != 2
|
|
484
|
+
or payload["worktree"] != str(worktree.resolve())
|
|
485
|
+
or payload["branch"] != baseline.branch
|
|
486
|
+
or (payload["rootDevice"], payload["rootInode"])
|
|
487
|
+
!= (baseline.root_device, baseline.root_inode)
|
|
488
|
+
or payload["baselineDigest"] != baseline.digest
|
|
489
|
+
or not isinstance(payload["generatedFiles"], list)
|
|
490
|
+
or payload["generatedFiles"] != sorted(set(payload["generatedFiles"]))
|
|
491
|
+
or not isinstance(payload["generatedEvidence"], list)
|
|
492
|
+
or payload["state"] not in {"started", "frozen"}
|
|
493
|
+
or not isinstance(payload["authorizedEvidence"], list)
|
|
494
|
+
or type(payload["pushSucceeded"]) is not bool
|
|
495
|
+
or digest != _baseline_digest(payload)
|
|
496
|
+
):
|
|
497
|
+
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
498
|
+
return {
|
|
499
|
+
"baselineDigest": payload["baselineDigest"],
|
|
500
|
+
"generatedFiles": payload["generatedFiles"],
|
|
501
|
+
"generatedEvidence": payload["generatedEvidence"],
|
|
502
|
+
"state": payload["state"],
|
|
503
|
+
"authorizedEvidence": payload["authorizedEvidence"],
|
|
504
|
+
"pushSucceeded": payload["pushSucceeded"],
|
|
505
|
+
"policyDigest": payload["policyDigest"],
|
|
506
|
+
"newAttempt": False,
|
|
507
|
+
}
|
|
508
|
+
current = set(ignored_file_inventory(worktree))
|
|
509
|
+
generated = tuple(sorted(
|
|
510
|
+
path for path in current
|
|
511
|
+
if any(
|
|
512
|
+
path_glob_matches(path, pattern)
|
|
513
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
514
|
+
)
|
|
515
|
+
))
|
|
516
|
+
if generated:
|
|
517
|
+
raise LifecycleError(
|
|
518
|
+
"landing-start generated paths are consumer-owned and protected: "
|
|
519
|
+
+ ", ".join(generated)
|
|
520
|
+
)
|
|
521
|
+
result = {
|
|
522
|
+
"baselineDigest": baseline.digest,
|
|
523
|
+
"generatedFiles": list(generated),
|
|
524
|
+
"generatedEvidence": [],
|
|
525
|
+
"state": "started",
|
|
526
|
+
"authorizedEvidence": [],
|
|
527
|
+
"pushSucceeded": False,
|
|
528
|
+
"policyDigest": landing_cleanup_policy_digest(profile),
|
|
529
|
+
}
|
|
530
|
+
payload = {
|
|
531
|
+
"contractVersion": 2,
|
|
532
|
+
"worktree": str(worktree.resolve()),
|
|
533
|
+
"branch": baseline.branch,
|
|
534
|
+
"rootDevice": baseline.root_device,
|
|
535
|
+
"rootInode": baseline.root_inode,
|
|
536
|
+
**result,
|
|
537
|
+
}
|
|
538
|
+
digest = _baseline_digest(payload)
|
|
539
|
+
durable_atomic_json(
|
|
540
|
+
path,
|
|
541
|
+
{**payload, "sha256": digest},
|
|
542
|
+
label="persist landing-attempt provenance",
|
|
543
|
+
)
|
|
544
|
+
return {**result, "newAttempt": True}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def verified_landing_scratch_evidence(
|
|
548
|
+
profile: WorktreeProfile,
|
|
549
|
+
worktree: Path,
|
|
550
|
+
*,
|
|
551
|
+
expected_baseline_digest: str | None = None,
|
|
552
|
+
landing_start_files: tuple[str, ...] = (),
|
|
553
|
+
) -> tuple[dict[str, Any], ...]:
|
|
554
|
+
"""Return frozen regular-file identities for the authorized generator delta."""
|
|
555
|
+
baseline = load_artifact_baseline(worktree)
|
|
556
|
+
if (
|
|
557
|
+
expected_baseline_digest is not None
|
|
558
|
+
and baseline.digest != expected_baseline_digest
|
|
559
|
+
):
|
|
560
|
+
raise LifecycleError("artifact provenance baseline changed during landing")
|
|
561
|
+
current = set(ignored_file_inventory(worktree))
|
|
562
|
+
initial = current.intersection(baseline.initial_ignored_files)
|
|
563
|
+
initial_generated = sorted(
|
|
564
|
+
path for path in initial
|
|
565
|
+
if any(
|
|
566
|
+
path_glob_matches(path, pattern)
|
|
567
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
if initial_generated:
|
|
571
|
+
raise LifecycleError(
|
|
572
|
+
"artifact provenance baseline protects initial generated paths: "
|
|
573
|
+
+ ", ".join(initial_generated)
|
|
574
|
+
)
|
|
575
|
+
if landing_start_files:
|
|
576
|
+
raise LifecycleError(
|
|
577
|
+
"landing-start generated paths are consumer-owned and protected: "
|
|
578
|
+
+ ", ".join(sorted(landing_start_files))
|
|
579
|
+
)
|
|
580
|
+
candidates = tuple(sorted(
|
|
581
|
+
path
|
|
582
|
+
for path in current.difference(baseline.initial_ignored_files)
|
|
583
|
+
if path not in landing_start_files
|
|
584
|
+
if any(
|
|
585
|
+
path_glob_matches(path, pattern)
|
|
586
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
587
|
+
)
|
|
588
|
+
))
|
|
589
|
+
if not candidates:
|
|
590
|
+
return ()
|
|
591
|
+
with verified_worktree_root(
|
|
592
|
+
worktree,
|
|
593
|
+
baseline.root_device,
|
|
594
|
+
baseline.root_inode,
|
|
595
|
+
) as descriptor:
|
|
596
|
+
return tuple(
|
|
597
|
+
contained_regular_identity(descriptor, relative)
|
|
598
|
+
for relative in candidates
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def freeze_landing_artifact_evidence(
|
|
603
|
+
profile: WorktreeProfile,
|
|
604
|
+
worktree: Path,
|
|
605
|
+
*,
|
|
606
|
+
push_succeeded: bool,
|
|
607
|
+
) -> tuple[dict[str, Any], ...]:
|
|
608
|
+
"""Freeze or revalidate the exact output of one generator-capable push."""
|
|
609
|
+
attempt = landing_start_artifact_inventory(profile, worktree)
|
|
610
|
+
current = verified_landing_scratch_evidence(
|
|
611
|
+
profile,
|
|
612
|
+
worktree,
|
|
613
|
+
expected_baseline_digest=attempt["baselineDigest"],
|
|
614
|
+
landing_start_files=tuple(attempt["generatedFiles"]),
|
|
615
|
+
)
|
|
616
|
+
frozen = tuple(attempt["authorizedEvidence"])
|
|
617
|
+
if attempt["state"] == "frozen" and frozen != current:
|
|
618
|
+
raise LifecycleError(
|
|
619
|
+
"landing-generated evidence changed after it was frozen"
|
|
620
|
+
)
|
|
621
|
+
if attempt["state"] == "frozen":
|
|
622
|
+
return frozen
|
|
623
|
+
path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
|
|
624
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
625
|
+
payload = {
|
|
626
|
+
key: document[key]
|
|
627
|
+
for key in (
|
|
628
|
+
"contractVersion", "worktree", "branch", "rootDevice",
|
|
629
|
+
"rootInode", "baselineDigest", "generatedFiles",
|
|
630
|
+
"generatedEvidence", "policyDigest",
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
payload.update({
|
|
634
|
+
"state": "frozen",
|
|
635
|
+
"authorizedEvidence": list(current),
|
|
636
|
+
"pushSucceeded": push_succeeded,
|
|
637
|
+
})
|
|
638
|
+
digest = _baseline_digest(payload)
|
|
639
|
+
durable_atomic_json(
|
|
640
|
+
path,
|
|
641
|
+
{**payload, "sha256": digest},
|
|
642
|
+
label="freeze landing-generated evidence",
|
|
643
|
+
)
|
|
644
|
+
return current
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def reopen_frozen_landing_attempt(
|
|
648
|
+
profile: WorktreeProfile,
|
|
649
|
+
worktree: Path,
|
|
650
|
+
) -> tuple[dict[str, Any], ...]:
|
|
651
|
+
"""Validate a failed push boundary before permitting its generator to retry."""
|
|
652
|
+
frozen = freeze_landing_artifact_evidence(
|
|
653
|
+
profile, worktree, push_succeeded=False
|
|
654
|
+
)
|
|
655
|
+
path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
|
|
656
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
657
|
+
payload = {
|
|
658
|
+
key: document[key]
|
|
659
|
+
for key in (
|
|
660
|
+
"contractVersion", "worktree", "branch", "rootDevice",
|
|
661
|
+
"rootInode", "baselineDigest", "generatedFiles",
|
|
662
|
+
"generatedEvidence", "policyDigest",
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
payload.update({
|
|
666
|
+
"state": "started",
|
|
667
|
+
"authorizedEvidence": [],
|
|
668
|
+
"pushSucceeded": False,
|
|
669
|
+
})
|
|
670
|
+
digest = _baseline_digest(payload)
|
|
671
|
+
durable_atomic_json(
|
|
672
|
+
path,
|
|
673
|
+
{**payload, "sha256": digest},
|
|
674
|
+
label="reopen validated landing attempt",
|
|
675
|
+
)
|
|
676
|
+
return frozen
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def abandon_unfinished_landing_attempt(
|
|
680
|
+
worktree: Path,
|
|
681
|
+
) -> Path:
|
|
682
|
+
"""Archive an ambiguous started attempt without claiming or deleting files."""
|
|
683
|
+
path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
|
|
684
|
+
if not os.path.lexists(path):
|
|
685
|
+
raise LifecycleError("no pre-existing unfinished landing attempt to abandon")
|
|
686
|
+
try:
|
|
687
|
+
if path.is_symlink() or not path.is_file():
|
|
688
|
+
raise LifecycleError("landing-attempt provenance is not a regular file")
|
|
689
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
690
|
+
contract_version = document["contractVersion"]
|
|
691
|
+
keys = [
|
|
692
|
+
"contractVersion", "worktree", "branch", "rootDevice",
|
|
693
|
+
"rootInode", "baselineDigest", "generatedFiles",
|
|
694
|
+
"generatedEvidence", "state", "authorizedEvidence",
|
|
695
|
+
"pushSucceeded",
|
|
696
|
+
]
|
|
697
|
+
if contract_version == 2:
|
|
698
|
+
keys.append("policyDigest")
|
|
699
|
+
payload = {
|
|
700
|
+
key: document[key]
|
|
701
|
+
for key in keys
|
|
702
|
+
}
|
|
703
|
+
digest = document["sha256"]
|
|
704
|
+
metadata = os.lstat(worktree)
|
|
705
|
+
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
706
|
+
except LifecycleError:
|
|
707
|
+
raise
|
|
708
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
709
|
+
raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
|
|
710
|
+
if (
|
|
711
|
+
payload["contractVersion"] not in {1, 2}
|
|
712
|
+
or payload["worktree"] != str(worktree.resolve())
|
|
713
|
+
or payload["branch"] != branch
|
|
714
|
+
or not stat.S_ISDIR(metadata.st_mode)
|
|
715
|
+
or (payload["rootDevice"], payload["rootInode"])
|
|
716
|
+
!= (metadata.st_dev, metadata.st_ino)
|
|
717
|
+
or payload["state"] not in {"started", "frozen"}
|
|
718
|
+
or digest != _baseline_digest(payload)
|
|
719
|
+
):
|
|
720
|
+
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
721
|
+
archive = path.with_name(
|
|
722
|
+
f"{path.stem}.abandoned-{int(time() * 1_000_000)}.json"
|
|
723
|
+
)
|
|
724
|
+
durable_replace(path, archive, label="archive landing attempt")
|
|
725
|
+
return archive
|
|
726
|
+
|
|
727
|
+
|
|
53
728
|
def collect_facts(cwd: Path) -> RepoFacts:
|
|
54
729
|
root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
|
|
55
730
|
main = main_worktree(root)
|
|
@@ -121,28 +796,75 @@ def cleanup_assessment(
|
|
|
121
796
|
main: Path,
|
|
122
797
|
target: Path,
|
|
123
798
|
merge_target: str | None = None,
|
|
799
|
+
pr_state: str = "none",
|
|
800
|
+
verified_scratch_files: tuple[str, ...] = (),
|
|
801
|
+
verified_scratch_evidence: tuple[dict[str, Any], ...] = (),
|
|
124
802
|
) -> CleanupAssessment:
|
|
803
|
+
assessment = classify_cleanup(
|
|
804
|
+
profile,
|
|
805
|
+
collect_cleanup_facts(
|
|
806
|
+
main,
|
|
807
|
+
target,
|
|
808
|
+
merge_target=merge_target,
|
|
809
|
+
pr_state=pr_state,
|
|
810
|
+
),
|
|
811
|
+
verified_scratch_files=verified_scratch_files,
|
|
812
|
+
)
|
|
813
|
+
try:
|
|
814
|
+
return bind_cleanup_scratch_evidence(
|
|
815
|
+
profile,
|
|
816
|
+
assessment,
|
|
817
|
+
verified_scratch_evidence,
|
|
818
|
+
require_generator_evidence=True,
|
|
819
|
+
)
|
|
820
|
+
except LifecycleError as error:
|
|
821
|
+
return replace(
|
|
822
|
+
assessment,
|
|
823
|
+
reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def collect_cleanup_facts(
|
|
828
|
+
main: Path,
|
|
829
|
+
target: Path,
|
|
830
|
+
*,
|
|
831
|
+
merge_target: str | None = None,
|
|
832
|
+
pr_state: str = "none",
|
|
833
|
+
) -> CleanupFacts:
|
|
125
834
|
worktree = target.resolve()
|
|
126
|
-
|
|
835
|
+
root_metadata = worktree.stat()
|
|
127
836
|
branch = run(
|
|
128
837
|
["git", "-C", str(worktree), "branch", "--show-current"],
|
|
129
838
|
cwd=main,
|
|
130
839
|
check=False,
|
|
131
840
|
).stdout.strip()
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
841
|
+
tracked = set(run(
|
|
842
|
+
["git", "-C", str(worktree), "diff", "--name-only"],
|
|
843
|
+
cwd=main,
|
|
844
|
+
check=False,
|
|
845
|
+
).stdout.splitlines())
|
|
846
|
+
tracked.update(run(
|
|
847
|
+
["git", "-C", str(worktree), "diff", "--cached", "--name-only"],
|
|
848
|
+
cwd=main,
|
|
849
|
+
check=False,
|
|
850
|
+
).stdout.splitlines())
|
|
851
|
+
untracked = set(run(
|
|
852
|
+
["git", "-C", str(worktree), "ls-files", "--others", "--exclude-standard"],
|
|
853
|
+
cwd=main,
|
|
854
|
+
check=False,
|
|
855
|
+
).stdout.splitlines())
|
|
856
|
+
untracked.update(run(
|
|
857
|
+
[
|
|
858
|
+
"git", "-C", str(worktree), "ls-files", "--others", "--ignored",
|
|
859
|
+
"--exclude-standard",
|
|
860
|
+
],
|
|
140
861
|
cwd=main,
|
|
141
862
|
check=False,
|
|
142
|
-
).stdout
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
863
|
+
).stdout.splitlines())
|
|
864
|
+
# ANNAHMEN.md is governed separately: its bytes are returned before removal.
|
|
865
|
+
untracked.discard("ANNAHMEN.md")
|
|
866
|
+
merged = False
|
|
867
|
+
if branch:
|
|
146
868
|
main_branch = merge_target or run(
|
|
147
869
|
["git", "-C", str(main), "branch", "--show-current"],
|
|
148
870
|
cwd=main,
|
|
@@ -152,12 +874,653 @@ def cleanup_assessment(
|
|
|
152
874
|
["git", "merge-base", "--is-ancestor", branch, main_branch],
|
|
153
875
|
cwd=main,
|
|
154
876
|
check=False,
|
|
155
|
-
)
|
|
156
|
-
if merged.returncode != 0:
|
|
157
|
-
reasons.append(f"unmerged branch: {branch}")
|
|
877
|
+
).returncode == 0
|
|
158
878
|
assumptions_path = worktree / "ANNAHMEN.md"
|
|
159
879
|
assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
|
|
160
|
-
return
|
|
880
|
+
return CleanupFacts(
|
|
881
|
+
worktree=worktree,
|
|
882
|
+
branch=branch,
|
|
883
|
+
registered=worktree in registered_worktrees(main),
|
|
884
|
+
is_main=worktree == main.resolve(),
|
|
885
|
+
tracked_files=tuple(sorted(tracked)),
|
|
886
|
+
untracked_files=tuple(sorted(untracked)),
|
|
887
|
+
merged=merged,
|
|
888
|
+
pr_state=pr_state,
|
|
889
|
+
assumptions=assumptions,
|
|
890
|
+
root_device=root_metadata.st_dev,
|
|
891
|
+
root_inode=root_metadata.st_ino,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def classify_cleanup(
|
|
896
|
+
profile: WorktreeProfile,
|
|
897
|
+
facts: CleanupFacts,
|
|
898
|
+
*,
|
|
899
|
+
verified_scratch_files: tuple[str, ...] = (),
|
|
900
|
+
) -> CleanupAssessment:
|
|
901
|
+
reasons = []
|
|
902
|
+
if not facts.registered:
|
|
903
|
+
reasons.append("not a registered worktree")
|
|
904
|
+
if not facts.branch:
|
|
905
|
+
reasons.append("detached or unreadable branch")
|
|
906
|
+
if facts.branch in profile.protected_branches or facts.is_main:
|
|
907
|
+
reasons.append(f"protected worktree branch: {facts.branch or '<unknown>'}")
|
|
908
|
+
verified = set(verified_scratch_files)
|
|
909
|
+
missing_verified = sorted(verified.difference(facts.untracked_files))
|
|
910
|
+
if missing_verified:
|
|
911
|
+
reasons.append(
|
|
912
|
+
"verified scratch evidence no longer matches inventory: "
|
|
913
|
+
+ ", ".join(missing_verified)
|
|
914
|
+
)
|
|
915
|
+
scratch = sorted(
|
|
916
|
+
path for path in facts.untracked_files
|
|
917
|
+
if (
|
|
918
|
+
path in verified
|
|
919
|
+
or any(path_glob_matches(path, pattern) for pattern in profile.scratch_patterns)
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
non_scratch = sorted(set(facts.untracked_files).difference(scratch))
|
|
923
|
+
if facts.tracked_files:
|
|
924
|
+
reasons.append(
|
|
925
|
+
f"dirty worktree: tracked modifications: {', '.join(facts.tracked_files)}"
|
|
926
|
+
)
|
|
927
|
+
if non_scratch:
|
|
928
|
+
reasons.append(f"dirty worktree: untracked non-scratch: {', '.join(non_scratch)}")
|
|
929
|
+
if facts.pr_state == "open":
|
|
930
|
+
reasons.append("open PR")
|
|
931
|
+
if (
|
|
932
|
+
facts.branch
|
|
933
|
+
and facts.branch not in profile.protected_branches
|
|
934
|
+
and not facts.merged
|
|
935
|
+
):
|
|
936
|
+
reasons.append(f"unmerged branch: {facts.branch}")
|
|
937
|
+
return CleanupAssessment(
|
|
938
|
+
facts.worktree,
|
|
939
|
+
facts.branch,
|
|
940
|
+
facts.assumptions,
|
|
941
|
+
tuple(reasons),
|
|
942
|
+
facts.root_device,
|
|
943
|
+
facts.root_inode,
|
|
944
|
+
tuple(scratch),
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
@contextmanager
|
|
949
|
+
def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
|
|
950
|
+
"""Open the assessed worktree root without following a replacement symlink."""
|
|
951
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
952
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
953
|
+
descriptor = None
|
|
954
|
+
try:
|
|
955
|
+
descriptor = os.open(root, directory_flags | no_follow)
|
|
956
|
+
metadata = os.fstat(descriptor)
|
|
957
|
+
if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
|
|
958
|
+
raise LifecycleError("worktree root changed before removal")
|
|
959
|
+
yield descriptor
|
|
960
|
+
except OSError as error:
|
|
961
|
+
raise LifecycleError("worktree root changed before removal") from error
|
|
962
|
+
finally:
|
|
963
|
+
if descriptor is not None:
|
|
964
|
+
os.close(descriptor)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def remove_contained_regular(
|
|
968
|
+
root_descriptor: int,
|
|
969
|
+
relative: str,
|
|
970
|
+
expected_identity: dict[str, Any] | None = None,
|
|
971
|
+
) -> None:
|
|
972
|
+
"""Delete one exact assessed regular file without following path symlinks."""
|
|
973
|
+
path = PurePosixPath(relative)
|
|
974
|
+
if path.is_absolute() or not path.parts or any(
|
|
975
|
+
part in {"", ".", ".."} for part in path.parts
|
|
976
|
+
):
|
|
977
|
+
raise LifecycleError(f"unsafe scratch path: {relative}")
|
|
978
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
979
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
980
|
+
descriptors = []
|
|
981
|
+
try:
|
|
982
|
+
current = root_descriptor
|
|
983
|
+
for component in path.parts[:-1]:
|
|
984
|
+
current = os.open(
|
|
985
|
+
component,
|
|
986
|
+
directory_flags | no_follow,
|
|
987
|
+
dir_fd=current,
|
|
988
|
+
)
|
|
989
|
+
descriptors.append(current)
|
|
990
|
+
initial_metadata = os.stat(
|
|
991
|
+
path.name,
|
|
992
|
+
dir_fd=current,
|
|
993
|
+
follow_symlinks=False,
|
|
994
|
+
)
|
|
995
|
+
if not stat.S_ISREG(initial_metadata.st_mode):
|
|
996
|
+
raise LifecycleError(f"scratch path is not a regular file: {relative}")
|
|
997
|
+
file_descriptor = os.open(
|
|
998
|
+
path.name,
|
|
999
|
+
os.O_RDONLY | no_follow,
|
|
1000
|
+
dir_fd=current,
|
|
1001
|
+
)
|
|
1002
|
+
try:
|
|
1003
|
+
metadata = os.fstat(file_descriptor)
|
|
1004
|
+
digest = sha256()
|
|
1005
|
+
while chunk := os.read(file_descriptor, 128 * 1024):
|
|
1006
|
+
digest.update(chunk)
|
|
1007
|
+
finally:
|
|
1008
|
+
os.close(file_descriptor)
|
|
1009
|
+
if (
|
|
1010
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
1011
|
+
or (metadata.st_dev, metadata.st_ino)
|
|
1012
|
+
!= (initial_metadata.st_dev, initial_metadata.st_ino)
|
|
1013
|
+
):
|
|
1014
|
+
raise LifecycleError(f"scratch path changed before removal: {relative}")
|
|
1015
|
+
identity = {
|
|
1016
|
+
"path": relative,
|
|
1017
|
+
"device": metadata.st_dev,
|
|
1018
|
+
"inode": metadata.st_ino,
|
|
1019
|
+
"size": metadata.st_size,
|
|
1020
|
+
"sha256": digest.hexdigest(),
|
|
1021
|
+
}
|
|
1022
|
+
if expected_identity is not None and identity != expected_identity:
|
|
1023
|
+
raise LifecycleError(f"scratch path identity changed: {relative}")
|
|
1024
|
+
latest = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
1025
|
+
if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino):
|
|
1026
|
+
raise LifecycleError(f"scratch path changed before removal: {relative}")
|
|
1027
|
+
os.unlink(path.name, dir_fd=current)
|
|
1028
|
+
except OSError as error:
|
|
1029
|
+
raise LifecycleError(f"scratch path changed before removal: {relative}") from error
|
|
1030
|
+
finally:
|
|
1031
|
+
for descriptor in reversed(descriptors):
|
|
1032
|
+
os.close(descriptor)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def remove_authorized_scratch(
|
|
1036
|
+
profile: WorktreeProfile,
|
|
1037
|
+
root_descriptor: int,
|
|
1038
|
+
scratch_files: tuple[str, ...] | list[str],
|
|
1039
|
+
verified_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
1040
|
+
) -> None:
|
|
1041
|
+
"""Remove profile scratch or exact generator evidence without weakening overlaps."""
|
|
1042
|
+
evidence_by_path: dict[str, dict[str, Any]] = {}
|
|
1043
|
+
for item in verified_evidence:
|
|
1044
|
+
relative = item.get("path")
|
|
1045
|
+
if not isinstance(relative, str) or relative in evidence_by_path:
|
|
1046
|
+
raise LifecycleError("scratch evidence is incoherent")
|
|
1047
|
+
evidence_by_path[relative] = item
|
|
1048
|
+
unexpected = sorted(set(evidence_by_path).difference(scratch_files))
|
|
1049
|
+
if unexpected:
|
|
1050
|
+
raise LifecycleError(
|
|
1051
|
+
"scratch evidence is outside the assessed inventory: "
|
|
1052
|
+
+ ", ".join(unexpected)
|
|
1053
|
+
)
|
|
1054
|
+
for relative in scratch_files:
|
|
1055
|
+
generated = any(
|
|
1056
|
+
path_glob_matches(relative, pattern)
|
|
1057
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
1058
|
+
)
|
|
1059
|
+
profile_authorized = any(
|
|
1060
|
+
path_glob_matches(relative, pattern)
|
|
1061
|
+
for pattern in profile.scratch_patterns
|
|
1062
|
+
)
|
|
1063
|
+
expected = evidence_by_path.get(relative)
|
|
1064
|
+
if not generated and not profile_authorized:
|
|
1065
|
+
raise LifecycleError(
|
|
1066
|
+
f"canonical cleanup policy does not authorize scratch: {relative}"
|
|
1067
|
+
)
|
|
1068
|
+
if generated and expected is None:
|
|
1069
|
+
raise LifecycleError(
|
|
1070
|
+
f"landing-generated scratch evidence is missing: {relative}"
|
|
1071
|
+
)
|
|
1072
|
+
if profile_authorized and expected is None:
|
|
1073
|
+
raise LifecycleError(f"profile scratch evidence is missing: {relative}")
|
|
1074
|
+
if not profile_authorized and expected is None:
|
|
1075
|
+
raise LifecycleError(f"verified scratch evidence is missing: {relative}")
|
|
1076
|
+
for relative in scratch_files:
|
|
1077
|
+
expected = evidence_by_path[relative]
|
|
1078
|
+
remove_contained_regular(
|
|
1079
|
+
root_descriptor,
|
|
1080
|
+
relative,
|
|
1081
|
+
expected_identity=expected,
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def bind_cleanup_scratch_evidence(
|
|
1086
|
+
profile: WorktreeProfile,
|
|
1087
|
+
assessment: CleanupAssessment,
|
|
1088
|
+
verified_generator_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
1089
|
+
*,
|
|
1090
|
+
require_generator_evidence: bool = False,
|
|
1091
|
+
) -> CleanupAssessment:
|
|
1092
|
+
"""Freeze identity for every assessed scratch path at its authority boundary."""
|
|
1093
|
+
evidence_by_path = {
|
|
1094
|
+
item.get("path"): item
|
|
1095
|
+
for item in verified_generator_evidence
|
|
1096
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1097
|
+
}
|
|
1098
|
+
if len(evidence_by_path) != len(verified_generator_evidence):
|
|
1099
|
+
raise LifecycleError("scratch evidence is incoherent")
|
|
1100
|
+
unauthorized_evidence = sorted(
|
|
1101
|
+
relative
|
|
1102
|
+
for relative in evidence_by_path
|
|
1103
|
+
if not any(
|
|
1104
|
+
path_glob_matches(relative, pattern)
|
|
1105
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
1106
|
+
)
|
|
1107
|
+
)
|
|
1108
|
+
if unauthorized_evidence:
|
|
1109
|
+
raise LifecycleError(
|
|
1110
|
+
"generator evidence is outside canonical landing policy: "
|
|
1111
|
+
+ ", ".join(unauthorized_evidence)
|
|
1112
|
+
)
|
|
1113
|
+
scratch = set(assessment.scratch_files)
|
|
1114
|
+
if not set(evidence_by_path).issubset(scratch):
|
|
1115
|
+
raise LifecycleError("scratch evidence is outside the assessed inventory")
|
|
1116
|
+
profile_only = [
|
|
1117
|
+
relative
|
|
1118
|
+
for relative in assessment.scratch_files
|
|
1119
|
+
if not any(
|
|
1120
|
+
path_glob_matches(relative, pattern)
|
|
1121
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
1122
|
+
)
|
|
1123
|
+
]
|
|
1124
|
+
missing_generator = [
|
|
1125
|
+
relative
|
|
1126
|
+
for relative in assessment.scratch_files
|
|
1127
|
+
if any(
|
|
1128
|
+
path_glob_matches(relative, pattern)
|
|
1129
|
+
for pattern in profile.landing_generated_artifact_patterns
|
|
1130
|
+
)
|
|
1131
|
+
and relative not in evidence_by_path
|
|
1132
|
+
]
|
|
1133
|
+
if missing_generator and require_generator_evidence:
|
|
1134
|
+
raise LifecycleError(
|
|
1135
|
+
"landing-generated scratch evidence is missing: "
|
|
1136
|
+
+ ", ".join(missing_generator)
|
|
1137
|
+
)
|
|
1138
|
+
with verified_worktree_root(
|
|
1139
|
+
assessment.worktree,
|
|
1140
|
+
assessment.root_device,
|
|
1141
|
+
assessment.root_inode,
|
|
1142
|
+
) as descriptor:
|
|
1143
|
+
for relative in profile_only:
|
|
1144
|
+
evidence_by_path[relative] = contained_regular_identity(
|
|
1145
|
+
descriptor, relative
|
|
1146
|
+
)
|
|
1147
|
+
return replace(
|
|
1148
|
+
assessment,
|
|
1149
|
+
scratch_evidence=tuple(
|
|
1150
|
+
evidence_by_path[relative]
|
|
1151
|
+
for relative in assessment.scratch_files
|
|
1152
|
+
if relative in evidence_by_path
|
|
1153
|
+
),
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def contained_regular_identity(root_descriptor: int, relative: str) -> dict[str, Any]:
|
|
1158
|
+
"""Read one exact regular file identity without following path symlinks."""
|
|
1159
|
+
path = PurePosixPath(relative)
|
|
1160
|
+
if path.is_absolute() or not path.parts or any(
|
|
1161
|
+
part in {"", ".", ".."} for part in path.parts
|
|
1162
|
+
):
|
|
1163
|
+
raise LifecycleError(f"unsafe scratch path: {relative}")
|
|
1164
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1165
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1166
|
+
descriptors = []
|
|
1167
|
+
try:
|
|
1168
|
+
current = root_descriptor
|
|
1169
|
+
for component in path.parts[:-1]:
|
|
1170
|
+
current = os.open(
|
|
1171
|
+
component,
|
|
1172
|
+
directory_flags | no_follow,
|
|
1173
|
+
dir_fd=current,
|
|
1174
|
+
)
|
|
1175
|
+
descriptors.append(current)
|
|
1176
|
+
initial = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
1177
|
+
if not stat.S_ISREG(initial.st_mode):
|
|
1178
|
+
raise LifecycleError(
|
|
1179
|
+
f"scratch path is not a regular file: {relative}"
|
|
1180
|
+
)
|
|
1181
|
+
file_descriptor = os.open(
|
|
1182
|
+
path.name,
|
|
1183
|
+
os.O_RDONLY | no_follow,
|
|
1184
|
+
dir_fd=current,
|
|
1185
|
+
)
|
|
1186
|
+
try:
|
|
1187
|
+
metadata = os.fstat(file_descriptor)
|
|
1188
|
+
if (
|
|
1189
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
1190
|
+
or (metadata.st_dev, metadata.st_ino)
|
|
1191
|
+
!= (initial.st_dev, initial.st_ino)
|
|
1192
|
+
):
|
|
1193
|
+
raise LifecycleError(
|
|
1194
|
+
f"scratch path changed before inspection: {relative}"
|
|
1195
|
+
)
|
|
1196
|
+
digest = sha256()
|
|
1197
|
+
while chunk := os.read(file_descriptor, 128 * 1024):
|
|
1198
|
+
digest.update(chunk)
|
|
1199
|
+
finally:
|
|
1200
|
+
os.close(file_descriptor)
|
|
1201
|
+
return {
|
|
1202
|
+
"path": relative,
|
|
1203
|
+
"device": metadata.st_dev,
|
|
1204
|
+
"inode": metadata.st_ino,
|
|
1205
|
+
"size": metadata.st_size,
|
|
1206
|
+
"sha256": digest.hexdigest(),
|
|
1207
|
+
}
|
|
1208
|
+
except OSError as error:
|
|
1209
|
+
raise LifecycleError(f"scratch path changed before inspection: {relative}") from error
|
|
1210
|
+
finally:
|
|
1211
|
+
for descriptor in reversed(descriptors):
|
|
1212
|
+
os.close(descriptor)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def contained_untracked_identity(
|
|
1216
|
+
root_descriptor: int,
|
|
1217
|
+
relative: str,
|
|
1218
|
+
) -> dict[str, Any]:
|
|
1219
|
+
"""Read exact regular/symlink identity without following any symlink."""
|
|
1220
|
+
path = PurePosixPath(relative)
|
|
1221
|
+
if path.is_absolute() or not path.parts or any(
|
|
1222
|
+
part in {"", ".", ".."} for part in path.parts
|
|
1223
|
+
):
|
|
1224
|
+
raise LifecycleError(f"unsafe recovery path: {relative}")
|
|
1225
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1226
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1227
|
+
descriptors = []
|
|
1228
|
+
try:
|
|
1229
|
+
current = root_descriptor
|
|
1230
|
+
for component in path.parts[:-1]:
|
|
1231
|
+
current = os.open(
|
|
1232
|
+
component,
|
|
1233
|
+
directory_flags | no_follow,
|
|
1234
|
+
dir_fd=current,
|
|
1235
|
+
)
|
|
1236
|
+
descriptors.append(current)
|
|
1237
|
+
metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
1238
|
+
common = {
|
|
1239
|
+
"path": relative,
|
|
1240
|
+
"device": metadata.st_dev,
|
|
1241
|
+
"inode": metadata.st_ino,
|
|
1242
|
+
"size": metadata.st_size,
|
|
1243
|
+
}
|
|
1244
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
1245
|
+
target = os.readlink(path.name, dir_fd=current).encode(
|
|
1246
|
+
"utf-8", errors="surrogateescape"
|
|
1247
|
+
)
|
|
1248
|
+
return {**common, "kind": "symlink", "sha256": sha256(target).hexdigest()}
|
|
1249
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
1250
|
+
raise LifecycleError(f"unsupported recovery path type: {relative}")
|
|
1251
|
+
file_descriptor = os.open(
|
|
1252
|
+
path.name,
|
|
1253
|
+
os.O_RDONLY | no_follow,
|
|
1254
|
+
dir_fd=current,
|
|
1255
|
+
)
|
|
1256
|
+
try:
|
|
1257
|
+
opened = os.fstat(file_descriptor)
|
|
1258
|
+
if (opened.st_dev, opened.st_ino) != (
|
|
1259
|
+
metadata.st_dev, metadata.st_ino
|
|
1260
|
+
):
|
|
1261
|
+
raise LifecycleError(
|
|
1262
|
+
f"recovery path changed before inspection: {relative}"
|
|
1263
|
+
)
|
|
1264
|
+
digest = sha256()
|
|
1265
|
+
while chunk := os.read(file_descriptor, 128 * 1024):
|
|
1266
|
+
digest.update(chunk)
|
|
1267
|
+
finally:
|
|
1268
|
+
os.close(file_descriptor)
|
|
1269
|
+
return {**common, "kind": "regular", "sha256": digest.hexdigest()}
|
|
1270
|
+
except OSError as error:
|
|
1271
|
+
raise LifecycleError(
|
|
1272
|
+
f"recovery path changed before inspection: {relative}"
|
|
1273
|
+
) from error
|
|
1274
|
+
finally:
|
|
1275
|
+
for descriptor in reversed(descriptors):
|
|
1276
|
+
os.close(descriptor)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def remove_contained_untracked(
|
|
1280
|
+
root_descriptor: int,
|
|
1281
|
+
expected_identity: dict[str, Any],
|
|
1282
|
+
) -> None:
|
|
1283
|
+
"""Unlink one frozen regular file or symlink if its identity still matches."""
|
|
1284
|
+
relative = expected_identity.get("path")
|
|
1285
|
+
if not isinstance(relative, str):
|
|
1286
|
+
raise LifecycleError("recovery path evidence is incoherent")
|
|
1287
|
+
current = contained_untracked_identity(root_descriptor, relative)
|
|
1288
|
+
if current != expected_identity:
|
|
1289
|
+
raise LifecycleError(f"recovery path identity changed: {relative}")
|
|
1290
|
+
path = PurePosixPath(relative)
|
|
1291
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1292
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1293
|
+
descriptors = []
|
|
1294
|
+
try:
|
|
1295
|
+
parent = root_descriptor
|
|
1296
|
+
for component in path.parts[:-1]:
|
|
1297
|
+
parent = os.open(
|
|
1298
|
+
component,
|
|
1299
|
+
directory_flags | no_follow,
|
|
1300
|
+
dir_fd=parent,
|
|
1301
|
+
)
|
|
1302
|
+
descriptors.append(parent)
|
|
1303
|
+
latest = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
|
|
1304
|
+
if (latest.st_dev, latest.st_ino) != (
|
|
1305
|
+
expected_identity["device"], expected_identity["inode"]
|
|
1306
|
+
):
|
|
1307
|
+
raise LifecycleError(f"recovery path changed before removal: {relative}")
|
|
1308
|
+
os.unlink(path.name, dir_fd=parent)
|
|
1309
|
+
except OSError as error:
|
|
1310
|
+
raise LifecycleError(f"recovery path changed before removal: {relative}") from error
|
|
1311
|
+
finally:
|
|
1312
|
+
for descriptor in reversed(descriptors):
|
|
1313
|
+
os.close(descriptor)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
@dataclass(frozen=True)
|
|
1317
|
+
class SweepRow:
|
|
1318
|
+
kind: str
|
|
1319
|
+
path: str | None
|
|
1320
|
+
branch: str
|
|
1321
|
+
issue: str | None
|
|
1322
|
+
pr_state: str
|
|
1323
|
+
merged_into_main: bool
|
|
1324
|
+
last_commit_age_seconds: int
|
|
1325
|
+
removable: bool
|
|
1326
|
+
reasons: tuple[str, ...]
|
|
1327
|
+
verdict_reason: str
|
|
1328
|
+
scratch_files: tuple[str, ...] = ()
|
|
1329
|
+
assumptions: str = ""
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
@dataclass(frozen=True)
|
|
1333
|
+
class SweepReport:
|
|
1334
|
+
main_branch: str
|
|
1335
|
+
worktree_count: int
|
|
1336
|
+
local_branch_count: int
|
|
1337
|
+
merged_remote_branch_count: int
|
|
1338
|
+
rows: tuple[SweepRow, ...]
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
@dataclass(frozen=True)
|
|
1342
|
+
class SweepFactRow:
|
|
1343
|
+
path: Path | None
|
|
1344
|
+
branch: str
|
|
1345
|
+
pr_state: str
|
|
1346
|
+
merged_into_main: bool
|
|
1347
|
+
last_commit_age_seconds: int
|
|
1348
|
+
cleanup: CleanupFacts | None = None
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
@dataclass(frozen=True)
|
|
1352
|
+
class SweepFacts:
|
|
1353
|
+
main: Path
|
|
1354
|
+
main_branch: str
|
|
1355
|
+
worktree_count: int
|
|
1356
|
+
local_branch_count: int
|
|
1357
|
+
merged_remote_branch_count: int
|
|
1358
|
+
rows: tuple[SweepFactRow, ...]
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _worktree_branches(main: Path) -> tuple[dict[str, Path], tuple[Path, ...]]:
|
|
1362
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
|
|
1363
|
+
linked: dict[str, Path] = {}
|
|
1364
|
+
detached = []
|
|
1365
|
+
path: Path | None = None
|
|
1366
|
+
branch = ""
|
|
1367
|
+
for line in [*output.splitlines(), ""]:
|
|
1368
|
+
if line.startswith("worktree "):
|
|
1369
|
+
path = Path(line.split(" ", 1)[1]).resolve()
|
|
1370
|
+
branch = ""
|
|
1371
|
+
elif line.startswith("branch refs/heads/"):
|
|
1372
|
+
branch = line.removeprefix("branch refs/heads/")
|
|
1373
|
+
elif not line and path is not None:
|
|
1374
|
+
if branch:
|
|
1375
|
+
linked[branch] = path
|
|
1376
|
+
else:
|
|
1377
|
+
detached.append(path)
|
|
1378
|
+
path = None
|
|
1379
|
+
return linked, tuple(detached)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def collect_sweep_facts(
|
|
1383
|
+
profile: WorktreeProfile,
|
|
1384
|
+
main: Path,
|
|
1385
|
+
pr_lookup: Callable[[str], str],
|
|
1386
|
+
*,
|
|
1387
|
+
now: int | None = None,
|
|
1388
|
+
) -> SweepFacts:
|
|
1389
|
+
"""Gather the complete read-only inventory without making removal decisions."""
|
|
1390
|
+
main = main.resolve()
|
|
1391
|
+
main_branch = run(
|
|
1392
|
+
["git", "-C", str(main), "branch", "--show-current"], cwd=main
|
|
1393
|
+
).stdout.strip()
|
|
1394
|
+
linked, detached = _worktree_branches(main)
|
|
1395
|
+
refs = run(
|
|
1396
|
+
[
|
|
1397
|
+
"git", "for-each-ref",
|
|
1398
|
+
"--format=%(refname:short)\t%(committerdate:unix)",
|
|
1399
|
+
"refs/heads/",
|
|
1400
|
+
],
|
|
1401
|
+
cwd=main,
|
|
1402
|
+
).stdout.splitlines()
|
|
1403
|
+
timestamp = int(time()) if now is None else now
|
|
1404
|
+
rows: list[SweepFactRow] = []
|
|
1405
|
+
for line in refs:
|
|
1406
|
+
branch, commit_time = line.rsplit("\t", 1)
|
|
1407
|
+
path = linked.get(branch)
|
|
1408
|
+
pr_state = pr_lookup(branch)
|
|
1409
|
+
merged = run(
|
|
1410
|
+
["git", "merge-base", "--is-ancestor", branch, main_branch],
|
|
1411
|
+
cwd=main,
|
|
1412
|
+
check=False,
|
|
1413
|
+
).returncode == 0
|
|
1414
|
+
rows.append(SweepFactRow(
|
|
1415
|
+
path=path,
|
|
1416
|
+
branch=branch,
|
|
1417
|
+
pr_state=pr_state,
|
|
1418
|
+
merged_into_main=merged,
|
|
1419
|
+
last_commit_age_seconds=max(0, timestamp - int(commit_time)),
|
|
1420
|
+
cleanup=collect_cleanup_facts(
|
|
1421
|
+
main,
|
|
1422
|
+
path,
|
|
1423
|
+
merge_target=main_branch,
|
|
1424
|
+
pr_state=pr_state,
|
|
1425
|
+
) if path is not None else None,
|
|
1426
|
+
))
|
|
1427
|
+
for path in detached:
|
|
1428
|
+
commit_time = run(
|
|
1429
|
+
["git", "-C", str(path), "show", "-s", "--format=%ct", "HEAD"],
|
|
1430
|
+
cwd=main,
|
|
1431
|
+
).stdout.strip()
|
|
1432
|
+
rows.append(SweepFactRow(
|
|
1433
|
+
path=path,
|
|
1434
|
+
branch="",
|
|
1435
|
+
pr_state="none",
|
|
1436
|
+
merged_into_main=False,
|
|
1437
|
+
last_commit_age_seconds=max(0, timestamp - int(commit_time)),
|
|
1438
|
+
))
|
|
1439
|
+
remote_merged = run(
|
|
1440
|
+
[
|
|
1441
|
+
"git", "for-each-ref",
|
|
1442
|
+
f"--merged={main_branch}",
|
|
1443
|
+
"--format=%(refname:short)",
|
|
1444
|
+
"refs/remotes/",
|
|
1445
|
+
],
|
|
1446
|
+
cwd=main,
|
|
1447
|
+
check=False,
|
|
1448
|
+
).stdout.splitlines()
|
|
1449
|
+
return SweepFacts(
|
|
1450
|
+
main=main,
|
|
1451
|
+
main_branch=main_branch,
|
|
1452
|
+
worktree_count=len(linked) + len(detached),
|
|
1453
|
+
local_branch_count=len(refs),
|
|
1454
|
+
merged_remote_branch_count=len([
|
|
1455
|
+
branch for branch in remote_merged if branch and not branch.endswith("/HEAD")
|
|
1456
|
+
]),
|
|
1457
|
+
rows=tuple(rows),
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def classify_sweep(profile: WorktreeProfile, facts: SweepFacts) -> SweepReport:
|
|
1462
|
+
"""Apply profile policy to already-collected inventory facts."""
|
|
1463
|
+
rows = []
|
|
1464
|
+
for fact in facts.rows:
|
|
1465
|
+
if fact.cleanup is not None:
|
|
1466
|
+
assessment = classify_cleanup(profile, fact.cleanup)
|
|
1467
|
+
reasons = assessment.reasons
|
|
1468
|
+
scratch = assessment.scratch_files
|
|
1469
|
+
assumptions = assessment.assumptions
|
|
1470
|
+
elif fact.path is not None:
|
|
1471
|
+
reasons = ("detached or unreadable branch",)
|
|
1472
|
+
scratch = ()
|
|
1473
|
+
assumptions = ""
|
|
1474
|
+
else:
|
|
1475
|
+
reasons_list = []
|
|
1476
|
+
if fact.branch in profile.protected_branches:
|
|
1477
|
+
reasons_list.append(f"protected branch: {fact.branch}")
|
|
1478
|
+
if fact.pr_state == "open":
|
|
1479
|
+
reasons_list.append("open PR")
|
|
1480
|
+
if not fact.merged_into_main:
|
|
1481
|
+
reasons_list.append(f"unmerged branch: {fact.branch}")
|
|
1482
|
+
reasons = tuple(reasons_list)
|
|
1483
|
+
scratch = ()
|
|
1484
|
+
assumptions = ""
|
|
1485
|
+
rows.append(SweepRow(
|
|
1486
|
+
kind="worktree" if fact.path is not None else "branch",
|
|
1487
|
+
path=str(fact.path) if fact.path is not None else None,
|
|
1488
|
+
branch=fact.branch,
|
|
1489
|
+
issue=profile.issue_from_branch(fact.branch),
|
|
1490
|
+
pr_state=fact.pr_state,
|
|
1491
|
+
merged_into_main=fact.merged_into_main,
|
|
1492
|
+
last_commit_age_seconds=fact.last_commit_age_seconds,
|
|
1493
|
+
removable=not reasons,
|
|
1494
|
+
reasons=reasons,
|
|
1495
|
+
verdict_reason=(
|
|
1496
|
+
"; ".join(reasons)
|
|
1497
|
+
if reasons
|
|
1498
|
+
else (
|
|
1499
|
+
f"merged into {facts.main_branch}; scratch-only: {', '.join(scratch)}"
|
|
1500
|
+
if scratch
|
|
1501
|
+
else f"merged into {facts.main_branch}; no blocking work"
|
|
1502
|
+
)
|
|
1503
|
+
),
|
|
1504
|
+
scratch_files=scratch,
|
|
1505
|
+
assumptions=assumptions,
|
|
1506
|
+
))
|
|
1507
|
+
return SweepReport(
|
|
1508
|
+
main_branch=facts.main_branch,
|
|
1509
|
+
worktree_count=facts.worktree_count,
|
|
1510
|
+
local_branch_count=facts.local_branch_count,
|
|
1511
|
+
merged_remote_branch_count=facts.merged_remote_branch_count,
|
|
1512
|
+
rows=tuple(rows),
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def collect_sweep(
|
|
1517
|
+
profile: WorktreeProfile,
|
|
1518
|
+
main: Path,
|
|
1519
|
+
pr_lookup: Callable[[str], str],
|
|
1520
|
+
*,
|
|
1521
|
+
now: int | None = None,
|
|
1522
|
+
) -> SweepReport:
|
|
1523
|
+
return classify_sweep(profile, collect_sweep_facts(profile, main, pr_lookup, now=now))
|
|
161
1524
|
|
|
162
1525
|
|
|
163
1526
|
def edit_decision(
|