@ikon85/agent-workflow-kit 0.43.0 → 0.44.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/grill-me/SKILL.md +3 -1
- package/.agents/skills/grill-with-docs/SKILL.md +3 -1
- package/.agents/skills/kit-update/SKILL.md +9 -11
- package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
- package/.agents/skills/setup-workflow/SKILL.md +20 -18
- package/.agents/skills/setup-workflow/board-sync.md +4 -1
- package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.agents/skills/to-issues/SKILL.md +43 -0
- package/.agents/skills/wrapup/SKILL.md +98 -54
- package/.claude/skills/grill-me/SKILL.md +3 -1
- package/.claude/skills/grill-me-codex/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
- package/.claude/skills/kit-update/SKILL.md +9 -11
- package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
- package/.claude/skills/setup-workflow/SKILL.md +20 -18
- package/.claude/skills/setup-workflow/board-sync.md +4 -1
- package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.claude/skills/to-issues/SKILL.md +43 -0
- package/.claude/skills/wrapup/SKILL.md +98 -54
- package/README.md +249 -7
- package/agent-workflow-kit.package.json +42 -42
- package/package.json +16 -12
- package/scripts/marker_lib.py +3 -0
- package/scripts/profile_globs.py +26 -45
- package/scripts/release-delta-guard.mjs +26 -9
- package/scripts/worktree-lifecycle/README.md +57 -160
- package/scripts/worktree-lifecycle/capabilities.json +2 -3
- package/scripts/worktree-lifecycle/classify.py +480 -0
- package/scripts/worktree-lifecycle/cleanup.py +9 -29
- package/scripts/worktree-lifecycle/core.py +68 -1429
- package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
- package/scripts/worktree-lifecycle/profile.py +46 -29
- package/scripts/worktree-lifecycle/setup.py +7 -19
- package/scripts/wrapup-land.py +1292 -545
- package/src/consumer-migrations.json +1 -16
- package/src/lib/bundle.mjs +31 -1
- package/docs/adr/0001-consumer-divergence-policy.md +0 -53
- package/docs/adr/0002-capability-gated-orchestration.md +0 -70
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
- package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
- package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
- package/docs/agents/board-sync.md +0 -74
- package/docs/agents/code-review.md +0 -14
- package/docs/agents/workflow-capabilities.json +0 -28
- package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
- package/docs/research/benchlm-routing-source.md +0 -198
- package/docs/research/consumer-owned-protocol-files.md +0 -238
- package/docs/research/frontend-agent-benchmarks.md +0 -282
- package/docs/research/model-effort-routing-benchmarks.md +0 -261
- package/docs/research/provider-neutral-agent-routing.md +0 -207
- package/docs/research/wave-152-consumer-acceptance.md +0 -98
- package/docs/research/wave-43-script-hook-census.md +0 -128
- package/scripts/build-kit.mjs +0 -117
- package/scripts/build-kit.test.mjs +0 -207
- package/scripts/census/census.test.mjs +0 -252
- package/scripts/census/state.test.mjs +0 -15
- package/scripts/census/transaction.test.mjs +0 -103
- package/scripts/census-contract.test.mjs +0 -227
- package/scripts/check-kit-staleness.mjs +0 -45
- package/scripts/check-kit-staleness.test.mjs +0 -19
- package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
- package/scripts/codex-exec.test.mjs +0 -871
- package/scripts/grill-census-wiring-guard.mjs +0 -87
- package/scripts/grill-census-wiring-guard.test.mjs +0 -107
- package/scripts/kit-release.test.mjs +0 -273
- package/scripts/kit-update-pr.test.mjs +0 -151
- package/scripts/lib/audit-refs.mjs +0 -146
- package/scripts/lib/scrub.mjs +0 -113
- package/scripts/lib/scrub.test.mjs +0 -187
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
- package/scripts/portability_profile_scan.py +0 -53
- package/scripts/release-delta-guard.test.mjs +0 -174
- package/scripts/release-parity.test.mjs +0 -90
- package/scripts/release-state.test.mjs +0 -360
- package/scripts/test_anchor_table.py +0 -69
- package/scripts/test_board_bootstrap.py +0 -348
- package/scripts/test_board_sync.py +0 -208
- package/scripts/test_board_sync_create_idempotency.py +0 -117
- package/scripts/test_board_sync_wave_title.py +0 -120
- package/scripts/test_census_backstop.py +0 -712
- package/scripts/test_census_forward_contract.py +0 -91
- package/scripts/test_census_update_contract.test.mjs +0 -454
- package/scripts/test_codex_adapter_sync_contract.py +0 -169
- package/scripts/test_dist_kit_smoke.py +0 -220
- package/scripts/test_drift_guard_diagnostics.py +0 -295
- package/scripts/test_issue_claim_contract.py +0 -169
- package/scripts/test_kit_docs_language_census.py +0 -178
- package/scripts/test_marker_lib.py +0 -162
- package/scripts/test_orchestrate_wave_contract.py +0 -381
- package/scripts/test_pr_body_check.py +0 -264
- package/scripts/test_profile_globs.py +0 -280
- package/scripts/test_program_planning_contract.py +0 -133
- package/scripts/test_release_authorization_contract.py +0 -101
- package/scripts/test_render_anchor.py +0 -267
- package/scripts/test_retro_wrapup_contract.py +0 -153
- package/scripts/test_skill_code_review_seed.py +0 -89
- package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
- package/scripts/test_skill_frontmatter_lint.py +0 -234
- package/scripts/test_skill_gh_lint.py +0 -124
- package/scripts/test_skill_language_census.py +0 -254
- package/scripts/test_skill_optional_readiness.py +0 -171
- package/scripts/test_skill_portability_lint.py +0 -866
- package/scripts/test_skill_precommit_template.py +0 -30
- package/scripts/test_skill_publish_audit.py +0 -358
- package/scripts/test_skill_readiness_contract.py +0 -264
- package/scripts/test_skill_readiness_preflight.py +0 -180
- package/scripts/test_skill_required_readiness.py +0 -233
- package/scripts/test_skill_selfcontainment_lint.py +0 -255
- package/scripts/test_skill_setup_workflow_seeds.py +0 -691
- package/scripts/test_skill_stale_name_lint.py +0 -126
- package/scripts/test_skill_surface_refs.py +0 -300
- package/scripts/test_skill_trailing_artifact_lint.py +0 -71
- package/scripts/test_tdd_contract.py +0 -78
- package/scripts/test_worktree_ignore_seed.py +0 -320
- package/scripts/test_worktree_setup_base_guard.py +0 -140
- package/scripts/test_worktree_wrapup_contract.py +0 -1737
- package/scripts/test_wrapup_land.py +0 -428
- package/scripts/worktree-lifecycle/session.py +0 -1857
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
"""Consumer-neutral Worktree Lifecycle facts and decisions.
|
|
1
|
+
"""Consumer-neutral Worktree Lifecycle facts and decisions.
|
|
2
|
+
|
|
3
|
+
Teardown authority is not stored here. `classify.py` derives it from the
|
|
4
|
+
repository's current state at the moment of action, and this module consumes
|
|
5
|
+
exactly that assessment: it adds only the facts git status cannot answer — is
|
|
6
|
+
the path a registered worktree, is its branch protected, is there an open PR,
|
|
7
|
+
is the branch merged — and renders the one classification report instead of
|
|
8
|
+
formatting a second one.
|
|
9
|
+
"""
|
|
2
10
|
|
|
3
11
|
from __future__ import annotations
|
|
4
12
|
|
|
5
|
-
import importlib.util
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
13
|
import re
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
from contextlib import contextmanager
|
|
12
|
-
from dataclasses import dataclass, replace
|
|
13
|
-
from hashlib import sha256
|
|
14
|
-
from pathlib import Path, PurePosixPath
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
15
16
|
from time import time
|
|
16
17
|
from typing import Any, Callable
|
|
17
18
|
|
|
19
|
+
from classify import ClassificationError, assess, render_report
|
|
18
20
|
from profile import (
|
|
19
21
|
LifecycleError,
|
|
20
22
|
WorktreeProfile,
|
|
21
23
|
load_profile,
|
|
22
|
-
load_profile_text,
|
|
23
24
|
local_branch_exists,
|
|
24
25
|
main_worktree,
|
|
25
26
|
registered_worktrees,
|
|
@@ -28,103 +29,6 @@ from profile import (
|
|
|
28
29
|
|
|
29
30
|
_BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:merge|checkout))\b")
|
|
30
31
|
_BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
|
|
31
|
-
ARTIFACT_BASELINE_FILE = "awkit-artifact-baseline-v1.json"
|
|
32
|
-
LANDING_ATTEMPT_FILE = "awkit-landing-attempt-v1.json"
|
|
33
|
-
PROFILE_GLOBS_MODULE = "_agent_workflow_kit_profile_globs"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def load_profile_globs():
|
|
37
|
-
"""Load the one shared repository-relative glob dialect exactly once."""
|
|
38
|
-
module = sys.modules.get(PROFILE_GLOBS_MODULE)
|
|
39
|
-
if module is not None:
|
|
40
|
-
return module
|
|
41
|
-
path = Path(__file__).resolve().parents[1] / "profile_globs.py"
|
|
42
|
-
spec = importlib.util.spec_from_file_location(PROFILE_GLOBS_MODULE, path)
|
|
43
|
-
if spec is None or spec.loader is None:
|
|
44
|
-
raise ImportError(f"cannot load the shared profile glob dialect from {path}")
|
|
45
|
-
module = importlib.util.module_from_spec(spec)
|
|
46
|
-
sys.modules[PROFILE_GLOBS_MODULE] = module
|
|
47
|
-
spec.loader.exec_module(module)
|
|
48
|
-
return module
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# Consumer profile globs are matched here exactly as Workflow Advisories
|
|
52
|
-
# matches its own: one dialect, so an advisory and a deletion decision can
|
|
53
|
-
# never disagree about which repository-relative paths a pattern selects.
|
|
54
|
-
path_glob_matches = load_profile_globs().path_glob_matches
|
|
55
|
-
|
|
56
|
-
# Archived receipts are named from a contract-version-neutral stem plus the
|
|
57
|
-
# archived receipt's own contractVersion, so a v2 receipt is never filed as v1.
|
|
58
|
-
LANDING_ATTEMPT_ARCHIVE_STEM = "awkit-landing-attempt"
|
|
59
|
-
LANDING_ATTEMPT_CONTRACT_VERSION = 2
|
|
60
|
-
LANDING_ATTEMPT_KEYS = (
|
|
61
|
-
"contractVersion", "worktree", "branch", "rootDevice",
|
|
62
|
-
"rootInode", "baselineDigest", "generatedFiles",
|
|
63
|
-
"generatedEvidence", "state", "authorizedEvidence",
|
|
64
|
-
"pushSucceeded",
|
|
65
|
-
)
|
|
66
|
-
ABANDON_ATTEMPT_FLAG = "--abandon-unfinished-attempt"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class BaselineBackfillDeferred(LifecycleError):
|
|
70
|
-
"""A safe legacy baseline cannot be captured until consumer state changes."""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
class LegacyLandingAttempt(LifecycleError):
|
|
74
|
-
"""A coherent attempt journal predates the active contract and is not adoptable."""
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def durable_atomic_json(
|
|
78
|
-
path: Path,
|
|
79
|
-
document: dict[str, Any],
|
|
80
|
-
*,
|
|
81
|
-
label: str,
|
|
82
|
-
mode: int = 0o666,
|
|
83
|
-
sort_keys: bool = False,
|
|
84
|
-
) -> None:
|
|
85
|
-
"""Atomically replace one JSON journal and durably publish its directory entry."""
|
|
86
|
-
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
87
|
-
try:
|
|
88
|
-
descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
|
|
89
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
90
|
-
json.dump(
|
|
91
|
-
document,
|
|
92
|
-
handle,
|
|
93
|
-
ensure_ascii=False,
|
|
94
|
-
indent=2,
|
|
95
|
-
sort_keys=sort_keys,
|
|
96
|
-
)
|
|
97
|
-
handle.write("\n")
|
|
98
|
-
handle.flush()
|
|
99
|
-
os.fsync(handle.fileno())
|
|
100
|
-
os.replace(temporary, path)
|
|
101
|
-
directory_descriptor = os.open(
|
|
102
|
-
path.parent,
|
|
103
|
-
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
|
104
|
-
)
|
|
105
|
-
try:
|
|
106
|
-
os.fsync(directory_descriptor)
|
|
107
|
-
finally:
|
|
108
|
-
os.close(directory_descriptor)
|
|
109
|
-
except OSError as error:
|
|
110
|
-
temporary.unlink(missing_ok=True)
|
|
111
|
-
raise LifecycleError(f"cannot {label}: {error}") from error
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def durable_replace(source: Path, destination: Path, *, label: str) -> None:
|
|
115
|
-
"""Rename one journal durably without inspecting or claiming its payload files."""
|
|
116
|
-
try:
|
|
117
|
-
os.replace(source, destination)
|
|
118
|
-
directory_descriptor = os.open(
|
|
119
|
-
destination.parent,
|
|
120
|
-
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
|
121
|
-
)
|
|
122
|
-
try:
|
|
123
|
-
os.fsync(directory_descriptor)
|
|
124
|
-
finally:
|
|
125
|
-
os.close(directory_descriptor)
|
|
126
|
-
except OSError as error:
|
|
127
|
-
raise LifecycleError(f"cannot {label}: {error}") from error
|
|
128
32
|
|
|
129
33
|
|
|
130
34
|
@dataclass(frozen=True)
|
|
@@ -145,735 +49,35 @@ class Decision:
|
|
|
145
49
|
event_name: str = ""
|
|
146
50
|
|
|
147
51
|
|
|
148
|
-
@dataclass(frozen=True)
|
|
149
|
-
class CleanupAssessment:
|
|
150
|
-
worktree: Path
|
|
151
|
-
branch: str
|
|
152
|
-
assumptions: str
|
|
153
|
-
reasons: tuple[str, ...]
|
|
154
|
-
root_device: int
|
|
155
|
-
root_inode: int
|
|
156
|
-
scratch_files: tuple[str, ...] = ()
|
|
157
|
-
scratch_evidence: tuple[dict[str, Any], ...] = ()
|
|
158
|
-
|
|
159
|
-
@property
|
|
160
|
-
def removable(self) -> bool:
|
|
161
|
-
return not self.reasons
|
|
162
|
-
|
|
163
|
-
|
|
164
52
|
@dataclass(frozen=True)
|
|
165
53
|
class CleanupFacts:
|
|
166
54
|
worktree: Path
|
|
167
55
|
branch: str
|
|
168
56
|
registered: bool
|
|
169
57
|
is_main: bool
|
|
170
|
-
|
|
171
|
-
untracked_files: tuple[str, ...]
|
|
58
|
+
classification: Any
|
|
172
59
|
merged: bool
|
|
173
60
|
pr_state: str
|
|
174
61
|
assumptions: str
|
|
175
|
-
root_device: int
|
|
176
|
-
root_inode: int
|
|
177
62
|
|
|
178
63
|
|
|
179
64
|
@dataclass(frozen=True)
|
|
180
|
-
class
|
|
65
|
+
class CleanupAssessment:
|
|
181
66
|
worktree: Path
|
|
182
67
|
branch: str
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
initial_ignored_files: tuple[str, ...]
|
|
187
|
-
initial_untracked_files: tuple[str, ...]
|
|
188
|
-
digest: str
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def ignored_file_inventory(worktree: Path) -> tuple[str, ...]:
|
|
192
|
-
result = run(
|
|
193
|
-
[
|
|
194
|
-
"git", "ls-files", "--others", "--ignored",
|
|
195
|
-
"--exclude-standard", "-z",
|
|
196
|
-
],
|
|
197
|
-
cwd=worktree,
|
|
198
|
-
)
|
|
199
|
-
return tuple(sorted(path for path in result.stdout.split("\0") if path))
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def untracked_file_inventory(worktree: Path) -> tuple[str, ...]:
|
|
203
|
-
ordinary = run(
|
|
204
|
-
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
205
|
-
cwd=worktree,
|
|
206
|
-
).stdout.split("\0")
|
|
207
|
-
return tuple(sorted(
|
|
208
|
-
set(path for path in ordinary if path).union(ignored_file_inventory(worktree))
|
|
209
|
-
))
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def artifact_baseline_path(worktree: Path) -> Path:
|
|
213
|
-
result = run(
|
|
214
|
-
["git", "rev-parse", "--absolute-git-dir"],
|
|
215
|
-
cwd=worktree,
|
|
216
|
-
)
|
|
217
|
-
git_dir = Path(result.stdout.strip())
|
|
218
|
-
if not git_dir.is_absolute():
|
|
219
|
-
raise LifecycleError("artifact provenance baseline git dir is not absolute")
|
|
220
|
-
return git_dir / ARTIFACT_BASELINE_FILE
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def landing_attempt_path(worktree: Path) -> Path:
|
|
224
|
-
"""Return the one landing-attempt journal path beside the artifact baseline."""
|
|
225
|
-
return artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def landing_attempt_exists(path: Path) -> bool:
|
|
229
|
-
"""Classify journal presence without following a symlink at that name."""
|
|
230
|
-
return os.path.lexists(path)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def require_regular_landing_attempt(path: Path) -> None:
|
|
234
|
-
"""Refuse a journal name occupied by a symlink or any non-regular entry."""
|
|
235
|
-
if path.is_symlink() or not path.is_file():
|
|
236
|
-
raise LifecycleError("landing-attempt provenance is not a regular file")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def landing_attempt_keys(contract_version: Any) -> list[str]:
|
|
240
|
-
"""Return the exact journal key set recorded by one contract version."""
|
|
241
|
-
keys = list(LANDING_ATTEMPT_KEYS)
|
|
242
|
-
if contract_version == LANDING_ATTEMPT_CONTRACT_VERSION:
|
|
243
|
-
keys.append("policyDigest")
|
|
244
|
-
return keys
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def _baseline_payload(
|
|
248
|
-
*,
|
|
249
|
-
worktree: Path,
|
|
250
|
-
branch: str,
|
|
251
|
-
root_device: int,
|
|
252
|
-
root_inode: int,
|
|
253
|
-
setup_head: str,
|
|
254
|
-
initial_ignored_files: tuple[str, ...],
|
|
255
|
-
initial_untracked_files: tuple[str, ...],
|
|
256
|
-
) -> dict[str, Any]:
|
|
257
|
-
return {
|
|
258
|
-
"contractVersion": 2,
|
|
259
|
-
"worktree": str(worktree),
|
|
260
|
-
"branch": branch,
|
|
261
|
-
"rootDevice": root_device,
|
|
262
|
-
"rootInode": root_inode,
|
|
263
|
-
"setupHead": setup_head,
|
|
264
|
-
"initialIgnoredFiles": list(initial_ignored_files),
|
|
265
|
-
"initialUntrackedFiles": list(initial_untracked_files),
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def _baseline_digest(payload: dict[str, Any]) -> str:
|
|
270
|
-
encoded = json.dumps(
|
|
271
|
-
payload,
|
|
272
|
-
ensure_ascii=False,
|
|
273
|
-
separators=(",", ":"),
|
|
274
|
-
sort_keys=True,
|
|
275
|
-
).encode("utf-8")
|
|
276
|
-
return sha256(encoded).hexdigest()
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def landing_cleanup_policy_digest(profile: WorktreeProfile) -> str:
|
|
280
|
-
"""Bind one attempt to the exact ordered policy that nominated its files."""
|
|
281
|
-
return _baseline_digest({
|
|
282
|
-
"scratchPatterns": list(profile.scratch_patterns),
|
|
283
|
-
"landingGeneratedArtifactPatterns": list(
|
|
284
|
-
profile.landing_generated_artifact_patterns
|
|
285
|
-
),
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def capture_artifact_baseline(
|
|
290
|
-
worktree: Path,
|
|
291
|
-
*,
|
|
292
|
-
reject_ignored_patterns: tuple[str, ...] = (),
|
|
293
|
-
) -> ArtifactBaseline:
|
|
294
|
-
worktree = worktree.resolve()
|
|
295
|
-
metadata = worktree.stat()
|
|
296
|
-
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
297
|
-
setup_head = run(["git", "rev-parse", "HEAD"], cwd=worktree).stdout.strip()
|
|
298
|
-
ignored = ignored_file_inventory(worktree)
|
|
299
|
-
untracked = untracked_file_inventory(worktree)
|
|
300
|
-
blocked = tuple(sorted(
|
|
301
|
-
path
|
|
302
|
-
for path in ignored
|
|
303
|
-
if any(path_glob_matches(path, pattern) for pattern in reject_ignored_patterns)
|
|
304
|
-
))
|
|
305
|
-
if blocked:
|
|
306
|
-
raise BaselineBackfillDeferred(
|
|
307
|
-
"landing-start generated paths are consumer-owned and protected: "
|
|
308
|
-
+ ", ".join(blocked)
|
|
309
|
-
)
|
|
310
|
-
payload = _baseline_payload(
|
|
311
|
-
worktree=worktree,
|
|
312
|
-
branch=branch,
|
|
313
|
-
root_device=metadata.st_dev,
|
|
314
|
-
root_inode=metadata.st_ino,
|
|
315
|
-
setup_head=setup_head,
|
|
316
|
-
initial_ignored_files=ignored,
|
|
317
|
-
initial_untracked_files=untracked,
|
|
318
|
-
)
|
|
319
|
-
digest = _baseline_digest(payload)
|
|
320
|
-
path = artifact_baseline_path(worktree)
|
|
321
|
-
durable_atomic_json(
|
|
322
|
-
path,
|
|
323
|
-
{**payload, "sha256": digest},
|
|
324
|
-
label="write artifact provenance baseline",
|
|
325
|
-
)
|
|
326
|
-
return ArtifactBaseline(
|
|
327
|
-
worktree,
|
|
328
|
-
branch,
|
|
329
|
-
metadata.st_dev,
|
|
330
|
-
metadata.st_ino,
|
|
331
|
-
setup_head,
|
|
332
|
-
ignored,
|
|
333
|
-
untracked,
|
|
334
|
-
digest,
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def load_artifact_baseline(worktree: Path) -> ArtifactBaseline:
|
|
339
|
-
worktree = worktree.resolve()
|
|
340
|
-
path = artifact_baseline_path(worktree)
|
|
341
|
-
try:
|
|
342
|
-
if path.is_symlink() or not path.is_file():
|
|
343
|
-
raise LifecycleError("artifact provenance baseline is missing or not a regular file")
|
|
344
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
345
|
-
payload = {
|
|
346
|
-
key: document[key]
|
|
347
|
-
for key in (
|
|
348
|
-
"contractVersion",
|
|
349
|
-
"worktree",
|
|
350
|
-
"branch",
|
|
351
|
-
"rootDevice",
|
|
352
|
-
"rootInode",
|
|
353
|
-
"setupHead",
|
|
354
|
-
"initialIgnoredFiles",
|
|
355
|
-
"initialUntrackedFiles",
|
|
356
|
-
)
|
|
357
|
-
}
|
|
358
|
-
digest = document["sha256"]
|
|
359
|
-
except LifecycleError:
|
|
360
|
-
raise
|
|
361
|
-
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
362
|
-
raise LifecycleError(f"artifact provenance baseline is incoherent: {error}") from error
|
|
363
|
-
ignored = payload["initialIgnoredFiles"]
|
|
364
|
-
untracked = payload["initialUntrackedFiles"]
|
|
365
|
-
if (
|
|
366
|
-
payload["contractVersion"] != 2
|
|
367
|
-
or not isinstance(payload["worktree"], str)
|
|
368
|
-
or not isinstance(payload["branch"], str)
|
|
369
|
-
or not payload["branch"]
|
|
370
|
-
or type(payload["rootDevice"]) is not int
|
|
371
|
-
or type(payload["rootInode"]) is not int
|
|
372
|
-
or not isinstance(payload["setupHead"], str)
|
|
373
|
-
or re.fullmatch(r"[0-9a-f]{40,64}", payload["setupHead"]) is None
|
|
374
|
-
or not isinstance(ignored, list)
|
|
375
|
-
or not all(
|
|
376
|
-
isinstance(path_value, str)
|
|
377
|
-
and path_value
|
|
378
|
-
and not PurePosixPath(path_value).is_absolute()
|
|
379
|
-
and ".." not in PurePosixPath(path_value).parts
|
|
380
|
-
for path_value in ignored
|
|
381
|
-
)
|
|
382
|
-
or ignored != sorted(set(ignored))
|
|
383
|
-
or not isinstance(untracked, list)
|
|
384
|
-
or not all(
|
|
385
|
-
isinstance(path_value, str)
|
|
386
|
-
and path_value
|
|
387
|
-
and not PurePosixPath(path_value).is_absolute()
|
|
388
|
-
and ".." not in PurePosixPath(path_value).parts
|
|
389
|
-
for path_value in untracked
|
|
390
|
-
)
|
|
391
|
-
or untracked != sorted(set(untracked))
|
|
392
|
-
or not set(ignored).issubset(untracked)
|
|
393
|
-
or not isinstance(digest, str)
|
|
394
|
-
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
395
|
-
or digest != _baseline_digest(payload)
|
|
396
|
-
):
|
|
397
|
-
raise LifecycleError("artifact provenance baseline is incoherent")
|
|
398
|
-
try:
|
|
399
|
-
metadata = worktree.stat()
|
|
400
|
-
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
401
|
-
except (OSError, LifecycleError) as error:
|
|
402
|
-
raise LifecycleError(
|
|
403
|
-
f"artifact provenance baseline binding cannot be verified: {error}"
|
|
404
|
-
) from error
|
|
405
|
-
if (
|
|
406
|
-
payload["worktree"] != str(worktree)
|
|
407
|
-
or payload["branch"] != branch
|
|
408
|
-
or (payload["rootDevice"], payload["rootInode"])
|
|
409
|
-
!= (metadata.st_dev, metadata.st_ino)
|
|
410
|
-
):
|
|
411
|
-
raise LifecycleError("artifact provenance baseline binding does not match worktree")
|
|
412
|
-
return ArtifactBaseline(
|
|
413
|
-
worktree,
|
|
414
|
-
branch,
|
|
415
|
-
metadata.st_dev,
|
|
416
|
-
metadata.st_ino,
|
|
417
|
-
payload["setupHead"],
|
|
418
|
-
tuple(ignored),
|
|
419
|
-
tuple(untracked),
|
|
420
|
-
digest,
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def ensure_artifact_baseline(
|
|
425
|
-
worktree: Path,
|
|
426
|
-
*,
|
|
427
|
-
reject_ignored_patterns: tuple[str, ...] = (),
|
|
428
|
-
) -> ArtifactBaseline:
|
|
429
|
-
"""Load provenance or conservatively backfill one exact clean legacy worktree."""
|
|
430
|
-
path = artifact_baseline_path(worktree)
|
|
431
|
-
if os.path.lexists(path):
|
|
432
|
-
return load_artifact_baseline(worktree)
|
|
433
|
-
if landing_attempt_exists(landing_attempt_path(worktree)):
|
|
434
|
-
raise LifecycleError(
|
|
435
|
-
"artifact provenance baseline is missing while a landing attempt exists"
|
|
436
|
-
)
|
|
437
|
-
absolute = worktree.absolute()
|
|
438
|
-
try:
|
|
439
|
-
metadata = os.lstat(absolute)
|
|
440
|
-
except OSError as error:
|
|
441
|
-
raise LifecycleError(
|
|
442
|
-
f"legacy artifact baseline root cannot be inspected: {error}"
|
|
443
|
-
) from error
|
|
444
|
-
if not stat.S_ISDIR(metadata.st_mode) or absolute != worktree.resolve():
|
|
445
|
-
raise LifecycleError("legacy artifact baseline root is not an exact nofollow directory")
|
|
446
|
-
main = main_worktree(worktree)
|
|
447
|
-
if worktree.resolve() not in registered_worktrees(main):
|
|
448
|
-
raise LifecycleError("legacy artifact baseline requires an exact registered worktree")
|
|
449
|
-
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
450
|
-
if not branch:
|
|
451
|
-
raise LifecycleError("legacy artifact baseline requires an attached branch")
|
|
452
|
-
for command in (
|
|
453
|
-
["git", "diff", "--quiet"],
|
|
454
|
-
["git", "diff", "--cached", "--quiet"],
|
|
455
|
-
):
|
|
456
|
-
if run(command, cwd=worktree, check=False).returncode != 0:
|
|
457
|
-
raise BaselineBackfillDeferred(
|
|
458
|
-
"legacy artifact baseline requires a clean tracked worktree and index"
|
|
459
|
-
)
|
|
460
|
-
return capture_artifact_baseline(
|
|
461
|
-
worktree,
|
|
462
|
-
reject_ignored_patterns=reject_ignored_patterns,
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
def verified_landing_scratch_files(
|
|
467
|
-
profile: WorktreeProfile,
|
|
468
|
-
worktree: Path,
|
|
469
|
-
*,
|
|
470
|
-
expected_baseline_digest: str | None = None,
|
|
471
|
-
landing_start_files: tuple[str, ...] = (),
|
|
472
|
-
) -> tuple[str, ...]:
|
|
473
|
-
return tuple(
|
|
474
|
-
item["path"]
|
|
475
|
-
for item in verified_landing_scratch_evidence(
|
|
476
|
-
profile,
|
|
477
|
-
worktree,
|
|
478
|
-
expected_baseline_digest=expected_baseline_digest,
|
|
479
|
-
landing_start_files=landing_start_files,
|
|
480
|
-
)
|
|
481
|
-
)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def _classify_superseded_landing_attempt(
|
|
485
|
-
payload: dict[str, Any],
|
|
486
|
-
digest: Any,
|
|
487
|
-
baseline: ArtifactBaseline,
|
|
488
|
-
worktree: Path,
|
|
489
|
-
) -> None:
|
|
490
|
-
"""Separate a coherent pre-upgrade journal from genuinely corrupt evidence.
|
|
491
|
-
|
|
492
|
-
A journal written before the active contract can never be adopted, but it is
|
|
493
|
-
not damage: it has an exact, non-deleting route out. Only evidence that also
|
|
494
|
-
fails its own recorded contract is reported as corruption.
|
|
495
|
-
"""
|
|
496
|
-
if (
|
|
497
|
-
payload["contractVersion"] != 1
|
|
498
|
-
or payload["worktree"] != str(worktree.resolve())
|
|
499
|
-
or payload["branch"] != baseline.branch
|
|
500
|
-
or (payload["rootDevice"], payload["rootInode"])
|
|
501
|
-
!= (baseline.root_device, baseline.root_inode)
|
|
502
|
-
or payload["state"] not in {"started", "frozen"}
|
|
503
|
-
or digest != _baseline_digest(payload)
|
|
504
|
-
):
|
|
505
|
-
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
506
|
-
raise LegacyLandingAttempt(
|
|
507
|
-
"landing attempt was started under the superseded v1 journal contract "
|
|
508
|
-
"and cannot be adopted; archive it with "
|
|
509
|
-
f"`land {ABANDON_ATTEMPT_FLAG}` — that deletes and claims no file — "
|
|
510
|
-
"then rerun land"
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
def landing_start_artifact_inventory(
|
|
515
|
-
profile: WorktreeProfile,
|
|
516
|
-
worktree: Path,
|
|
517
|
-
) -> dict[str, Any]:
|
|
518
|
-
"""Persist/reuse the generated-path inventory preceding the landing build."""
|
|
519
|
-
baseline = ensure_artifact_baseline(
|
|
520
|
-
worktree,
|
|
521
|
-
reject_ignored_patterns=profile.landing_generated_artifact_patterns,
|
|
522
|
-
)
|
|
523
|
-
attempt_path = landing_attempt_path(worktree)
|
|
524
|
-
if landing_attempt_exists(attempt_path):
|
|
525
|
-
require_regular_landing_attempt(attempt_path)
|
|
526
|
-
try:
|
|
527
|
-
document = json.loads(attempt_path.read_text(encoding="utf-8"))
|
|
528
|
-
contract_version = document["contractVersion"]
|
|
529
|
-
payload = {
|
|
530
|
-
key: document[key]
|
|
531
|
-
for key in landing_attempt_keys(contract_version)
|
|
532
|
-
}
|
|
533
|
-
digest = document["sha256"]
|
|
534
|
-
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
535
|
-
raise LifecycleError(
|
|
536
|
-
f"landing-attempt provenance is incoherent: {error}"
|
|
537
|
-
) from error
|
|
538
|
-
if contract_version != LANDING_ATTEMPT_CONTRACT_VERSION:
|
|
539
|
-
_classify_superseded_landing_attempt(
|
|
540
|
-
payload, digest, baseline, worktree
|
|
541
|
-
)
|
|
542
|
-
if payload["policyDigest"] != landing_cleanup_policy_digest(profile):
|
|
543
|
-
raise LifecycleError(
|
|
544
|
-
"landing cleanup policy changed after attempt start; "
|
|
545
|
-
"abandon the unfinished attempt before retrying"
|
|
546
|
-
)
|
|
547
|
-
if (
|
|
548
|
-
payload["contractVersion"] != LANDING_ATTEMPT_CONTRACT_VERSION
|
|
549
|
-
or payload["worktree"] != str(worktree.resolve())
|
|
550
|
-
or payload["branch"] != baseline.branch
|
|
551
|
-
or (payload["rootDevice"], payload["rootInode"])
|
|
552
|
-
!= (baseline.root_device, baseline.root_inode)
|
|
553
|
-
or payload["baselineDigest"] != baseline.digest
|
|
554
|
-
or not isinstance(payload["generatedFiles"], list)
|
|
555
|
-
or payload["generatedFiles"] != sorted(set(payload["generatedFiles"]))
|
|
556
|
-
or not isinstance(payload["generatedEvidence"], list)
|
|
557
|
-
or payload["state"] not in {"started", "frozen"}
|
|
558
|
-
or not isinstance(payload["authorizedEvidence"], list)
|
|
559
|
-
or type(payload["pushSucceeded"]) is not bool
|
|
560
|
-
or digest != _baseline_digest(payload)
|
|
561
|
-
):
|
|
562
|
-
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
563
|
-
return {
|
|
564
|
-
"baselineDigest": payload["baselineDigest"],
|
|
565
|
-
"generatedFiles": payload["generatedFiles"],
|
|
566
|
-
"generatedEvidence": payload["generatedEvidence"],
|
|
567
|
-
"state": payload["state"],
|
|
568
|
-
"authorizedEvidence": payload["authorizedEvidence"],
|
|
569
|
-
"pushSucceeded": payload["pushSucceeded"],
|
|
570
|
-
"policyDigest": payload["policyDigest"],
|
|
571
|
-
"newAttempt": False,
|
|
572
|
-
}
|
|
573
|
-
current = set(ignored_file_inventory(worktree))
|
|
574
|
-
generated = tuple(sorted(
|
|
575
|
-
path for path in current
|
|
576
|
-
if any(
|
|
577
|
-
path_glob_matches(path, pattern)
|
|
578
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
579
|
-
)
|
|
580
|
-
))
|
|
581
|
-
if generated:
|
|
582
|
-
raise LifecycleError(
|
|
583
|
-
"landing-start generated paths are consumer-owned and protected: "
|
|
584
|
-
+ ", ".join(generated)
|
|
585
|
-
)
|
|
586
|
-
result = {
|
|
587
|
-
"baselineDigest": baseline.digest,
|
|
588
|
-
"generatedFiles": list(generated),
|
|
589
|
-
"generatedEvidence": [],
|
|
590
|
-
"state": "started",
|
|
591
|
-
"authorizedEvidence": [],
|
|
592
|
-
"pushSucceeded": False,
|
|
593
|
-
"policyDigest": landing_cleanup_policy_digest(profile),
|
|
594
|
-
}
|
|
595
|
-
payload = {
|
|
596
|
-
"contractVersion": LANDING_ATTEMPT_CONTRACT_VERSION,
|
|
597
|
-
"worktree": str(worktree.resolve()),
|
|
598
|
-
"branch": baseline.branch,
|
|
599
|
-
"rootDevice": baseline.root_device,
|
|
600
|
-
"rootInode": baseline.root_inode,
|
|
601
|
-
**result,
|
|
602
|
-
}
|
|
603
|
-
digest = _baseline_digest(payload)
|
|
604
|
-
durable_atomic_json(
|
|
605
|
-
attempt_path,
|
|
606
|
-
{**payload, "sha256": digest},
|
|
607
|
-
label="persist landing-attempt provenance",
|
|
608
|
-
)
|
|
609
|
-
return {**result, "newAttempt": True}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
def verified_landing_scratch_evidence(
|
|
613
|
-
profile: WorktreeProfile,
|
|
614
|
-
worktree: Path,
|
|
615
|
-
*,
|
|
616
|
-
expected_baseline_digest: str | None = None,
|
|
617
|
-
landing_start_files: tuple[str, ...] = (),
|
|
618
|
-
) -> tuple[dict[str, Any], ...]:
|
|
619
|
-
"""Return frozen regular-file identities for the authorized generator delta."""
|
|
620
|
-
baseline = load_artifact_baseline(worktree)
|
|
621
|
-
if (
|
|
622
|
-
expected_baseline_digest is not None
|
|
623
|
-
and baseline.digest != expected_baseline_digest
|
|
624
|
-
):
|
|
625
|
-
raise LifecycleError("artifact provenance baseline changed during landing")
|
|
626
|
-
current = set(ignored_file_inventory(worktree))
|
|
627
|
-
initial = current.intersection(baseline.initial_ignored_files)
|
|
628
|
-
initial_generated = sorted(
|
|
629
|
-
path for path in initial
|
|
630
|
-
if any(
|
|
631
|
-
path_glob_matches(path, pattern)
|
|
632
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
633
|
-
)
|
|
634
|
-
)
|
|
635
|
-
if initial_generated:
|
|
636
|
-
raise LifecycleError(
|
|
637
|
-
"artifact provenance baseline protects initial generated paths: "
|
|
638
|
-
+ ", ".join(initial_generated)
|
|
639
|
-
)
|
|
640
|
-
if landing_start_files:
|
|
641
|
-
raise LifecycleError(
|
|
642
|
-
"landing-start generated paths are consumer-owned and protected: "
|
|
643
|
-
+ ", ".join(sorted(landing_start_files))
|
|
644
|
-
)
|
|
645
|
-
candidates = tuple(sorted(
|
|
646
|
-
path
|
|
647
|
-
for path in current.difference(baseline.initial_ignored_files)
|
|
648
|
-
if path not in landing_start_files
|
|
649
|
-
if any(
|
|
650
|
-
path_glob_matches(path, pattern)
|
|
651
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
652
|
-
)
|
|
653
|
-
))
|
|
654
|
-
if not candidates:
|
|
655
|
-
return ()
|
|
656
|
-
with verified_worktree_root(
|
|
657
|
-
worktree,
|
|
658
|
-
baseline.root_device,
|
|
659
|
-
baseline.root_inode,
|
|
660
|
-
) as descriptor:
|
|
661
|
-
return tuple(
|
|
662
|
-
contained_regular_identity(descriptor, relative)
|
|
663
|
-
for relative in candidates
|
|
664
|
-
)
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def freeze_landing_artifact_evidence(
|
|
668
|
-
profile: WorktreeProfile,
|
|
669
|
-
worktree: Path,
|
|
670
|
-
*,
|
|
671
|
-
push_succeeded: bool,
|
|
672
|
-
) -> tuple[dict[str, Any], ...]:
|
|
673
|
-
"""Freeze or revalidate the exact output of one generator-capable push."""
|
|
674
|
-
attempt = landing_start_artifact_inventory(profile, worktree)
|
|
675
|
-
current = verified_landing_scratch_evidence(
|
|
676
|
-
profile,
|
|
677
|
-
worktree,
|
|
678
|
-
expected_baseline_digest=attempt["baselineDigest"],
|
|
679
|
-
landing_start_files=tuple(attempt["generatedFiles"]),
|
|
680
|
-
)
|
|
681
|
-
frozen = tuple(attempt["authorizedEvidence"])
|
|
682
|
-
if attempt["state"] == "frozen" and frozen != current:
|
|
683
|
-
raise LifecycleError(
|
|
684
|
-
"landing-generated evidence changed after it was frozen"
|
|
685
|
-
)
|
|
686
|
-
if attempt["state"] == "frozen":
|
|
687
|
-
return frozen
|
|
688
|
-
path = landing_attempt_path(worktree)
|
|
689
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
690
|
-
payload = {
|
|
691
|
-
key: document[key]
|
|
692
|
-
for key in (
|
|
693
|
-
"contractVersion", "worktree", "branch", "rootDevice",
|
|
694
|
-
"rootInode", "baselineDigest", "generatedFiles",
|
|
695
|
-
"generatedEvidence", "policyDigest",
|
|
696
|
-
)
|
|
697
|
-
}
|
|
698
|
-
payload.update({
|
|
699
|
-
"state": "frozen",
|
|
700
|
-
"authorizedEvidence": list(current),
|
|
701
|
-
"pushSucceeded": push_succeeded,
|
|
702
|
-
})
|
|
703
|
-
digest = _baseline_digest(payload)
|
|
704
|
-
durable_atomic_json(
|
|
705
|
-
path,
|
|
706
|
-
{**payload, "sha256": digest},
|
|
707
|
-
label="freeze landing-generated evidence",
|
|
708
|
-
)
|
|
709
|
-
return current
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
def reopen_frozen_landing_attempt(
|
|
713
|
-
profile: WorktreeProfile,
|
|
714
|
-
worktree: Path,
|
|
715
|
-
) -> tuple[dict[str, Any], ...]:
|
|
716
|
-
"""Validate a failed push boundary before permitting its generator to retry."""
|
|
717
|
-
frozen = freeze_landing_artifact_evidence(
|
|
718
|
-
profile, worktree, push_succeeded=False
|
|
719
|
-
)
|
|
720
|
-
path = landing_attempt_path(worktree)
|
|
721
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
722
|
-
payload = {
|
|
723
|
-
key: document[key]
|
|
724
|
-
for key in (
|
|
725
|
-
"contractVersion", "worktree", "branch", "rootDevice",
|
|
726
|
-
"rootInode", "baselineDigest", "generatedFiles",
|
|
727
|
-
"generatedEvidence", "policyDigest",
|
|
728
|
-
)
|
|
729
|
-
}
|
|
730
|
-
payload.update({
|
|
731
|
-
"state": "started",
|
|
732
|
-
"authorizedEvidence": [],
|
|
733
|
-
"pushSucceeded": False,
|
|
734
|
-
})
|
|
735
|
-
digest = _baseline_digest(payload)
|
|
736
|
-
durable_atomic_json(
|
|
737
|
-
path,
|
|
738
|
-
{**payload, "sha256": digest},
|
|
739
|
-
label="reopen validated landing attempt",
|
|
740
|
-
)
|
|
741
|
-
return frozen
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def abandon_unfinished_landing_attempt(
|
|
745
|
-
worktree: Path,
|
|
746
|
-
) -> Path:
|
|
747
|
-
"""Archive an ambiguous started attempt without claiming or deleting files."""
|
|
748
|
-
path = landing_attempt_path(worktree)
|
|
749
|
-
if not landing_attempt_exists(path):
|
|
750
|
-
raise LifecycleError("no pre-existing unfinished landing attempt to abandon")
|
|
751
|
-
try:
|
|
752
|
-
require_regular_landing_attempt(path)
|
|
753
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
754
|
-
payload = {
|
|
755
|
-
key: document[key]
|
|
756
|
-
for key in landing_attempt_keys(document["contractVersion"])
|
|
757
|
-
}
|
|
758
|
-
digest = document["sha256"]
|
|
759
|
-
metadata = os.lstat(worktree)
|
|
760
|
-
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
761
|
-
except LifecycleError:
|
|
762
|
-
raise
|
|
763
|
-
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
764
|
-
raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
|
|
765
|
-
if (
|
|
766
|
-
payload["contractVersion"] not in {1, 2}
|
|
767
|
-
or payload["worktree"] != str(worktree.resolve())
|
|
768
|
-
or payload["branch"] != branch
|
|
769
|
-
or not stat.S_ISDIR(metadata.st_mode)
|
|
770
|
-
or (payload["rootDevice"], payload["rootInode"])
|
|
771
|
-
!= (metadata.st_dev, metadata.st_ino)
|
|
772
|
-
or payload["state"] not in {"started", "frozen"}
|
|
773
|
-
or digest != _baseline_digest(payload)
|
|
774
|
-
):
|
|
775
|
-
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
776
|
-
archive = path.with_name(
|
|
777
|
-
f"{LANDING_ATTEMPT_ARCHIVE_STEM}.v{payload['contractVersion']}"
|
|
778
|
-
f".abandoned-{int(time() * 1_000_000)}.json"
|
|
779
|
-
)
|
|
780
|
-
durable_replace(path, archive, label="archive landing attempt")
|
|
781
|
-
return archive
|
|
782
|
-
|
|
68
|
+
assumptions: str
|
|
69
|
+
reasons: tuple[str, ...]
|
|
70
|
+
classification: Any
|
|
783
71
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
) -> tuple[dict[str, Any], ...]:
|
|
788
|
-
"""Revalidate one frozen landing attempt against canonical policy alone.
|
|
72
|
+
@property
|
|
73
|
+
def removable(self) -> bool:
|
|
74
|
+
return not self.reasons
|
|
789
75
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
identity must additionally still be named by canonical policy and still
|
|
796
|
-
match byte-for-byte on disk, so a narrowed canonical policy, a changed file,
|
|
797
|
-
and any pre-existing state stop the recovery instead of being deleted.
|
|
798
|
-
"""
|
|
799
|
-
path = landing_attempt_path(worktree)
|
|
800
|
-
if not landing_attempt_exists(path):
|
|
801
|
-
raise LifecycleError(
|
|
802
|
-
"canonical cleanup recovery requires the landing attempt that froze "
|
|
803
|
-
"this teardown's evidence"
|
|
804
|
-
)
|
|
805
|
-
try:
|
|
806
|
-
require_regular_landing_attempt(path)
|
|
807
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
808
|
-
contract_version = document["contractVersion"]
|
|
809
|
-
payload = {
|
|
810
|
-
key: document[key]
|
|
811
|
-
for key in landing_attempt_keys(contract_version)
|
|
812
|
-
}
|
|
813
|
-
digest = document["sha256"]
|
|
814
|
-
metadata = os.lstat(worktree)
|
|
815
|
-
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
816
|
-
except LifecycleError:
|
|
817
|
-
raise
|
|
818
|
-
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
819
|
-
raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
|
|
820
|
-
if (
|
|
821
|
-
contract_version != LANDING_ATTEMPT_CONTRACT_VERSION
|
|
822
|
-
or payload["worktree"] != str(worktree.resolve())
|
|
823
|
-
or payload["branch"] != branch
|
|
824
|
-
or not stat.S_ISDIR(metadata.st_mode)
|
|
825
|
-
or (payload["rootDevice"], payload["rootInode"])
|
|
826
|
-
!= (metadata.st_dev, metadata.st_ino)
|
|
827
|
-
or not isinstance(payload["generatedFiles"], list)
|
|
828
|
-
or not isinstance(payload["authorizedEvidence"], list)
|
|
829
|
-
or type(payload["pushSucceeded"]) is not bool
|
|
830
|
-
or digest != _baseline_digest(payload)
|
|
831
|
-
):
|
|
832
|
-
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
833
|
-
if payload["state"] != "frozen" or not payload["pushSucceeded"]:
|
|
834
|
-
raise LifecycleError(
|
|
835
|
-
"canonical cleanup recovery requires a frozen landing attempt from a "
|
|
836
|
-
f"completed push; archive an unfinished attempt with `{ABANDON_ATTEMPT_FLAG}`"
|
|
837
|
-
)
|
|
838
|
-
if payload["generatedFiles"]:
|
|
839
|
-
raise LifecycleError(
|
|
840
|
-
"landing-start generated paths are consumer-owned and protected: "
|
|
841
|
-
+ ", ".join(payload["generatedFiles"])
|
|
842
|
-
)
|
|
843
|
-
baseline = load_artifact_baseline(worktree)
|
|
844
|
-
if payload["baselineDigest"] != baseline.digest:
|
|
845
|
-
raise LifecycleError("artifact provenance baseline changed during landing")
|
|
846
|
-
frozen = tuple(payload["authorizedEvidence"])
|
|
847
|
-
relatives: list[str] = []
|
|
848
|
-
for item in frozen:
|
|
849
|
-
relative = item.get("path") if isinstance(item, dict) else None
|
|
850
|
-
if not isinstance(relative, str) or relative in relatives:
|
|
851
|
-
raise LifecycleError("scratch evidence is incoherent")
|
|
852
|
-
relatives.append(relative)
|
|
853
|
-
outside = sorted(
|
|
854
|
-
relative for relative in relatives
|
|
855
|
-
if not any(
|
|
856
|
-
path_glob_matches(relative, pattern)
|
|
857
|
-
for pattern in canonical.landing_generated_artifact_patterns
|
|
858
|
-
)
|
|
859
|
-
)
|
|
860
|
-
if outside:
|
|
861
|
-
raise LifecycleError(
|
|
862
|
-
"landing-attempt evidence is outside canonical cleanup policy: "
|
|
863
|
-
+ ", ".join(outside)
|
|
864
|
-
)
|
|
865
|
-
with verified_worktree_root(
|
|
866
|
-
worktree,
|
|
867
|
-
baseline.root_device,
|
|
868
|
-
baseline.root_inode,
|
|
869
|
-
) as descriptor:
|
|
870
|
-
current = tuple(
|
|
871
|
-
contained_regular_identity(descriptor, relative)
|
|
872
|
-
for relative in relatives
|
|
873
|
-
)
|
|
874
|
-
if current != frozen:
|
|
875
|
-
raise LifecycleError("landing-generated evidence changed after it was frozen")
|
|
876
|
-
return current
|
|
76
|
+
@property
|
|
77
|
+
def scratch_files(self) -> tuple[str, ...]:
|
|
78
|
+
if self.classification is None:
|
|
79
|
+
return ()
|
|
80
|
+
return tuple(entry.path for entry in self.classification.scratch)
|
|
877
81
|
|
|
878
82
|
|
|
879
83
|
def collect_facts(cwd: Path) -> RepoFacts:
|
|
@@ -942,39 +146,6 @@ def is_tracked(root: Path, relative: str) -> bool:
|
|
|
942
146
|
return result.returncode == 0
|
|
943
147
|
|
|
944
148
|
|
|
945
|
-
def cleanup_assessment(
|
|
946
|
-
profile: WorktreeProfile,
|
|
947
|
-
main: Path,
|
|
948
|
-
target: Path,
|
|
949
|
-
merge_target: str | None = None,
|
|
950
|
-
pr_state: str = "none",
|
|
951
|
-
verified_scratch_files: tuple[str, ...] = (),
|
|
952
|
-
verified_scratch_evidence: tuple[dict[str, Any], ...] = (),
|
|
953
|
-
) -> CleanupAssessment:
|
|
954
|
-
assessment = classify_cleanup(
|
|
955
|
-
profile,
|
|
956
|
-
collect_cleanup_facts(
|
|
957
|
-
main,
|
|
958
|
-
target,
|
|
959
|
-
merge_target=merge_target,
|
|
960
|
-
pr_state=pr_state,
|
|
961
|
-
),
|
|
962
|
-
verified_scratch_files=verified_scratch_files,
|
|
963
|
-
)
|
|
964
|
-
try:
|
|
965
|
-
return bind_cleanup_scratch_evidence(
|
|
966
|
-
profile,
|
|
967
|
-
assessment,
|
|
968
|
-
verified_scratch_evidence,
|
|
969
|
-
require_generator_evidence=True,
|
|
970
|
-
)
|
|
971
|
-
except LifecycleError as error:
|
|
972
|
-
return replace(
|
|
973
|
-
assessment,
|
|
974
|
-
reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
|
|
975
|
-
)
|
|
976
|
-
|
|
977
|
-
|
|
978
149
|
def collect_cleanup_facts(
|
|
979
150
|
main: Path,
|
|
980
151
|
target: Path,
|
|
@@ -982,38 +153,21 @@ def collect_cleanup_facts(
|
|
|
982
153
|
merge_target: str | None = None,
|
|
983
154
|
pr_state: str = "none",
|
|
984
155
|
) -> CleanupFacts:
|
|
156
|
+
"""Read every fact a removal verdict needs, deciding nothing.
|
|
157
|
+
|
|
158
|
+
The file taxonomy comes from `classify.assess`, which is also the object the
|
|
159
|
+
removal step consumes — preview and action can never disagree.
|
|
160
|
+
"""
|
|
985
161
|
worktree = target.resolve()
|
|
986
|
-
root_metadata = worktree.stat()
|
|
987
162
|
branch = run(
|
|
988
163
|
["git", "-C", str(worktree), "branch", "--show-current"],
|
|
989
164
|
cwd=main,
|
|
990
165
|
check=False,
|
|
991
166
|
).stdout.strip()
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
).stdout.splitlines())
|
|
997
|
-
tracked.update(run(
|
|
998
|
-
["git", "-C", str(worktree), "diff", "--cached", "--name-only"],
|
|
999
|
-
cwd=main,
|
|
1000
|
-
check=False,
|
|
1001
|
-
).stdout.splitlines())
|
|
1002
|
-
untracked = set(run(
|
|
1003
|
-
["git", "-C", str(worktree), "ls-files", "--others", "--exclude-standard"],
|
|
1004
|
-
cwd=main,
|
|
1005
|
-
check=False,
|
|
1006
|
-
).stdout.splitlines())
|
|
1007
|
-
untracked.update(run(
|
|
1008
|
-
[
|
|
1009
|
-
"git", "-C", str(worktree), "ls-files", "--others", "--ignored",
|
|
1010
|
-
"--exclude-standard",
|
|
1011
|
-
],
|
|
1012
|
-
cwd=main,
|
|
1013
|
-
check=False,
|
|
1014
|
-
).stdout.splitlines())
|
|
1015
|
-
# ANNAHMEN.md is governed separately: its bytes are returned before removal.
|
|
1016
|
-
untracked.discard("ANNAHMEN.md")
|
|
167
|
+
try:
|
|
168
|
+
classification = assess(worktree, main)
|
|
169
|
+
except ClassificationError as error:
|
|
170
|
+
raise LifecycleError(str(error)) from error
|
|
1017
171
|
merged = False
|
|
1018
172
|
if branch:
|
|
1019
173
|
main_branch = merge_target or run(
|
|
@@ -1026,6 +180,7 @@ def collect_cleanup_facts(
|
|
|
1026
180
|
cwd=main,
|
|
1027
181
|
check=False,
|
|
1028
182
|
).returncode == 0
|
|
183
|
+
# ANNAHMEN.md is governed separately: its bytes are returned before removal.
|
|
1029
184
|
assumptions_path = worktree / "ANNAHMEN.md"
|
|
1030
185
|
assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
|
|
1031
186
|
return CleanupFacts(
|
|
@@ -1033,22 +188,14 @@ def collect_cleanup_facts(
|
|
|
1033
188
|
branch=branch,
|
|
1034
189
|
registered=worktree in registered_worktrees(main),
|
|
1035
190
|
is_main=worktree == main.resolve(),
|
|
1036
|
-
|
|
1037
|
-
untracked_files=tuple(sorted(untracked)),
|
|
191
|
+
classification=classification,
|
|
1038
192
|
merged=merged,
|
|
1039
193
|
pr_state=pr_state,
|
|
1040
194
|
assumptions=assumptions,
|
|
1041
|
-
root_device=root_metadata.st_dev,
|
|
1042
|
-
root_inode=root_metadata.st_ino,
|
|
1043
195
|
)
|
|
1044
196
|
|
|
1045
197
|
|
|
1046
|
-
def classify_cleanup(
|
|
1047
|
-
profile: WorktreeProfile,
|
|
1048
|
-
facts: CleanupFacts,
|
|
1049
|
-
*,
|
|
1050
|
-
verified_scratch_files: tuple[str, ...] = (),
|
|
1051
|
-
) -> CleanupAssessment:
|
|
198
|
+
def classify_cleanup(profile: WorktreeProfile, facts: CleanupFacts) -> CleanupAssessment:
|
|
1052
199
|
reasons = []
|
|
1053
200
|
if not facts.registered:
|
|
1054
201
|
reasons.append("not a registered worktree")
|
|
@@ -1056,27 +203,8 @@ def classify_cleanup(
|
|
|
1056
203
|
reasons.append("detached or unreadable branch")
|
|
1057
204
|
if facts.branch in profile.protected_branches or facts.is_main:
|
|
1058
205
|
reasons.append(f"protected worktree branch: {facts.branch or '<unknown>'}")
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
if missing_verified:
|
|
1062
|
-
reasons.append(
|
|
1063
|
-
"verified scratch evidence no longer matches inventory: "
|
|
1064
|
-
+ ", ".join(missing_verified)
|
|
1065
|
-
)
|
|
1066
|
-
scratch = sorted(
|
|
1067
|
-
path for path in facts.untracked_files
|
|
1068
|
-
if (
|
|
1069
|
-
path in verified
|
|
1070
|
-
or any(path_glob_matches(path, pattern) for pattern in profile.scratch_patterns)
|
|
1071
|
-
)
|
|
1072
|
-
)
|
|
1073
|
-
non_scratch = sorted(set(facts.untracked_files).difference(scratch))
|
|
1074
|
-
if facts.tracked_files:
|
|
1075
|
-
reasons.append(
|
|
1076
|
-
f"dirty worktree: tracked modifications: {', '.join(facts.tracked_files)}"
|
|
1077
|
-
)
|
|
1078
|
-
if non_scratch:
|
|
1079
|
-
reasons.append(f"dirty worktree: untracked non-scratch: {', '.join(non_scratch)}")
|
|
206
|
+
if facts.classification is not None and facts.classification.blocks:
|
|
207
|
+
reasons.append(render_report(facts.classification))
|
|
1080
208
|
if facts.pr_state == "open":
|
|
1081
209
|
reasons.append("open PR")
|
|
1082
210
|
if (
|
|
@@ -1090,518 +218,8 @@ def classify_cleanup(
|
|
|
1090
218
|
facts.branch,
|
|
1091
219
|
facts.assumptions,
|
|
1092
220
|
tuple(reasons),
|
|
1093
|
-
facts.
|
|
1094
|
-
facts.root_inode,
|
|
1095
|
-
tuple(scratch),
|
|
1096
|
-
)
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
@contextmanager
|
|
1100
|
-
def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
|
|
1101
|
-
"""Open the assessed worktree root without following a replacement symlink."""
|
|
1102
|
-
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1103
|
-
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1104
|
-
descriptor = None
|
|
1105
|
-
try:
|
|
1106
|
-
descriptor = os.open(root, directory_flags | no_follow)
|
|
1107
|
-
metadata = os.fstat(descriptor)
|
|
1108
|
-
if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
|
|
1109
|
-
raise LifecycleError("worktree root changed before removal")
|
|
1110
|
-
yield descriptor
|
|
1111
|
-
except OSError as error:
|
|
1112
|
-
raise LifecycleError("worktree root changed before removal") from error
|
|
1113
|
-
finally:
|
|
1114
|
-
if descriptor is not None:
|
|
1115
|
-
os.close(descriptor)
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
def remove_contained_regular(
|
|
1119
|
-
root_descriptor: int,
|
|
1120
|
-
relative: str,
|
|
1121
|
-
expected_identity: dict[str, Any] | None = None,
|
|
1122
|
-
) -> None:
|
|
1123
|
-
"""Delete one exact assessed regular file without following path symlinks."""
|
|
1124
|
-
path = PurePosixPath(relative)
|
|
1125
|
-
if path.is_absolute() or not path.parts or any(
|
|
1126
|
-
part in {"", ".", ".."} for part in path.parts
|
|
1127
|
-
):
|
|
1128
|
-
raise LifecycleError(f"unsafe scratch path: {relative}")
|
|
1129
|
-
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1130
|
-
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1131
|
-
descriptors = []
|
|
1132
|
-
try:
|
|
1133
|
-
current = root_descriptor
|
|
1134
|
-
for component in path.parts[:-1]:
|
|
1135
|
-
current = os.open(
|
|
1136
|
-
component,
|
|
1137
|
-
directory_flags | no_follow,
|
|
1138
|
-
dir_fd=current,
|
|
1139
|
-
)
|
|
1140
|
-
descriptors.append(current)
|
|
1141
|
-
initial_metadata = os.stat(
|
|
1142
|
-
path.name,
|
|
1143
|
-
dir_fd=current,
|
|
1144
|
-
follow_symlinks=False,
|
|
1145
|
-
)
|
|
1146
|
-
if not stat.S_ISREG(initial_metadata.st_mode):
|
|
1147
|
-
raise LifecycleError(f"scratch path is not a regular file: {relative}")
|
|
1148
|
-
file_descriptor = os.open(
|
|
1149
|
-
path.name,
|
|
1150
|
-
os.O_RDONLY | no_follow,
|
|
1151
|
-
dir_fd=current,
|
|
1152
|
-
)
|
|
1153
|
-
try:
|
|
1154
|
-
metadata = os.fstat(file_descriptor)
|
|
1155
|
-
digest = sha256()
|
|
1156
|
-
while chunk := os.read(file_descriptor, 128 * 1024):
|
|
1157
|
-
digest.update(chunk)
|
|
1158
|
-
finally:
|
|
1159
|
-
os.close(file_descriptor)
|
|
1160
|
-
if (
|
|
1161
|
-
not stat.S_ISREG(metadata.st_mode)
|
|
1162
|
-
or (metadata.st_dev, metadata.st_ino)
|
|
1163
|
-
!= (initial_metadata.st_dev, initial_metadata.st_ino)
|
|
1164
|
-
):
|
|
1165
|
-
raise LifecycleError(f"scratch path changed before removal: {relative}")
|
|
1166
|
-
identity = {
|
|
1167
|
-
"path": relative,
|
|
1168
|
-
"device": metadata.st_dev,
|
|
1169
|
-
"inode": metadata.st_ino,
|
|
1170
|
-
"size": metadata.st_size,
|
|
1171
|
-
"sha256": digest.hexdigest(),
|
|
1172
|
-
}
|
|
1173
|
-
if expected_identity is not None and identity != expected_identity:
|
|
1174
|
-
raise LifecycleError(f"scratch path identity changed: {relative}")
|
|
1175
|
-
latest = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
1176
|
-
if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino):
|
|
1177
|
-
raise LifecycleError(f"scratch path changed before removal: {relative}")
|
|
1178
|
-
os.unlink(path.name, dir_fd=current)
|
|
1179
|
-
except OSError as error:
|
|
1180
|
-
raise LifecycleError(f"scratch path changed before removal: {relative}") from error
|
|
1181
|
-
finally:
|
|
1182
|
-
for descriptor in reversed(descriptors):
|
|
1183
|
-
os.close(descriptor)
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
def remove_authorized_scratch(
|
|
1187
|
-
profile: WorktreeProfile,
|
|
1188
|
-
root_descriptor: int,
|
|
1189
|
-
scratch_files: tuple[str, ...] | list[str],
|
|
1190
|
-
verified_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
1191
|
-
) -> None:
|
|
1192
|
-
"""Remove profile scratch or exact generator evidence without weakening overlaps."""
|
|
1193
|
-
evidence_by_path: dict[str, dict[str, Any]] = {}
|
|
1194
|
-
for item in verified_evidence:
|
|
1195
|
-
relative = item.get("path")
|
|
1196
|
-
if not isinstance(relative, str) or relative in evidence_by_path:
|
|
1197
|
-
raise LifecycleError("scratch evidence is incoherent")
|
|
1198
|
-
evidence_by_path[relative] = item
|
|
1199
|
-
unexpected = sorted(set(evidence_by_path).difference(scratch_files))
|
|
1200
|
-
if unexpected:
|
|
1201
|
-
raise LifecycleError(
|
|
1202
|
-
"scratch evidence is outside the assessed inventory: "
|
|
1203
|
-
+ ", ".join(unexpected)
|
|
1204
|
-
)
|
|
1205
|
-
for relative in scratch_files:
|
|
1206
|
-
generated = any(
|
|
1207
|
-
path_glob_matches(relative, pattern)
|
|
1208
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
1209
|
-
)
|
|
1210
|
-
profile_authorized = any(
|
|
1211
|
-
path_glob_matches(relative, pattern)
|
|
1212
|
-
for pattern in profile.scratch_patterns
|
|
1213
|
-
)
|
|
1214
|
-
expected = evidence_by_path.get(relative)
|
|
1215
|
-
if not generated and not profile_authorized:
|
|
1216
|
-
raise LifecycleError(
|
|
1217
|
-
f"canonical cleanup policy does not authorize scratch: {relative}"
|
|
1218
|
-
)
|
|
1219
|
-
if generated and expected is None:
|
|
1220
|
-
raise LifecycleError(
|
|
1221
|
-
f"landing-generated scratch evidence is missing: {relative}"
|
|
1222
|
-
)
|
|
1223
|
-
if generated and expected.get("kind") == "symlink":
|
|
1224
|
-
raise LifecycleError(
|
|
1225
|
-
f"landing-generated scratch path is not a regular file: {relative}"
|
|
1226
|
-
)
|
|
1227
|
-
if profile_authorized and expected is None:
|
|
1228
|
-
raise LifecycleError(f"profile scratch evidence is missing: {relative}")
|
|
1229
|
-
if not profile_authorized and expected is None:
|
|
1230
|
-
raise LifecycleError(f"verified scratch evidence is missing: {relative}")
|
|
1231
|
-
for relative in scratch_files:
|
|
1232
|
-
expected = evidence_by_path[relative]
|
|
1233
|
-
if expected.get("kind") == "symlink":
|
|
1234
|
-
remove_contained_untracked(
|
|
1235
|
-
root_descriptor,
|
|
1236
|
-
expected,
|
|
1237
|
-
require_contained_symlink_target=True,
|
|
1238
|
-
)
|
|
1239
|
-
else:
|
|
1240
|
-
remove_contained_regular(
|
|
1241
|
-
root_descriptor,
|
|
1242
|
-
relative,
|
|
1243
|
-
expected_identity=expected,
|
|
1244
|
-
)
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
def bind_cleanup_scratch_evidence(
|
|
1248
|
-
profile: WorktreeProfile,
|
|
1249
|
-
assessment: CleanupAssessment,
|
|
1250
|
-
verified_generator_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
|
|
1251
|
-
*,
|
|
1252
|
-
require_generator_evidence: bool = False,
|
|
1253
|
-
) -> CleanupAssessment:
|
|
1254
|
-
"""Freeze identity for every assessed scratch path at its authority boundary."""
|
|
1255
|
-
evidence_by_path = {
|
|
1256
|
-
item.get("path"): item
|
|
1257
|
-
for item in verified_generator_evidence
|
|
1258
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1259
|
-
}
|
|
1260
|
-
if len(evidence_by_path) != len(verified_generator_evidence):
|
|
1261
|
-
raise LifecycleError("scratch evidence is incoherent")
|
|
1262
|
-
unauthorized_evidence = sorted(
|
|
1263
|
-
relative
|
|
1264
|
-
for relative in evidence_by_path
|
|
1265
|
-
if not any(
|
|
1266
|
-
path_glob_matches(relative, pattern)
|
|
1267
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
1268
|
-
)
|
|
221
|
+
facts.classification,
|
|
1269
222
|
)
|
|
1270
|
-
if unauthorized_evidence:
|
|
1271
|
-
raise LifecycleError(
|
|
1272
|
-
"generator evidence is outside canonical landing policy: "
|
|
1273
|
-
+ ", ".join(unauthorized_evidence)
|
|
1274
|
-
)
|
|
1275
|
-
scratch = set(assessment.scratch_files)
|
|
1276
|
-
if not set(evidence_by_path).issubset(scratch):
|
|
1277
|
-
raise LifecycleError("scratch evidence is outside the assessed inventory")
|
|
1278
|
-
profile_only = [
|
|
1279
|
-
relative
|
|
1280
|
-
for relative in assessment.scratch_files
|
|
1281
|
-
if not any(
|
|
1282
|
-
path_glob_matches(relative, pattern)
|
|
1283
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
1284
|
-
)
|
|
1285
|
-
]
|
|
1286
|
-
missing_generator = [
|
|
1287
|
-
relative
|
|
1288
|
-
for relative in assessment.scratch_files
|
|
1289
|
-
if any(
|
|
1290
|
-
path_glob_matches(relative, pattern)
|
|
1291
|
-
for pattern in profile.landing_generated_artifact_patterns
|
|
1292
|
-
)
|
|
1293
|
-
and relative not in evidence_by_path
|
|
1294
|
-
]
|
|
1295
|
-
if missing_generator and require_generator_evidence:
|
|
1296
|
-
raise LifecycleError(
|
|
1297
|
-
"landing-generated scratch evidence is missing: "
|
|
1298
|
-
+ ", ".join(missing_generator)
|
|
1299
|
-
)
|
|
1300
|
-
with verified_worktree_root(
|
|
1301
|
-
assessment.worktree,
|
|
1302
|
-
assessment.root_device,
|
|
1303
|
-
assessment.root_inode,
|
|
1304
|
-
) as descriptor:
|
|
1305
|
-
for relative in profile_only:
|
|
1306
|
-
evidence_by_path[relative] = contained_profile_scratch_identity(
|
|
1307
|
-
descriptor, relative
|
|
1308
|
-
)
|
|
1309
|
-
return replace(
|
|
1310
|
-
assessment,
|
|
1311
|
-
scratch_evidence=tuple(
|
|
1312
|
-
evidence_by_path[relative]
|
|
1313
|
-
for relative in assessment.scratch_files
|
|
1314
|
-
if relative in evidence_by_path
|
|
1315
|
-
),
|
|
1316
|
-
)
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
def contained_regular_identity(root_descriptor: int, relative: str) -> dict[str, Any]:
|
|
1320
|
-
"""Read one exact regular file identity without following path symlinks."""
|
|
1321
|
-
path = PurePosixPath(relative)
|
|
1322
|
-
if path.is_absolute() or not path.parts or any(
|
|
1323
|
-
part in {"", ".", ".."} for part in path.parts
|
|
1324
|
-
):
|
|
1325
|
-
raise LifecycleError(f"unsafe scratch path: {relative}")
|
|
1326
|
-
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1327
|
-
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1328
|
-
descriptors = []
|
|
1329
|
-
try:
|
|
1330
|
-
current = root_descriptor
|
|
1331
|
-
for component in path.parts[:-1]:
|
|
1332
|
-
current = os.open(
|
|
1333
|
-
component,
|
|
1334
|
-
directory_flags | no_follow,
|
|
1335
|
-
dir_fd=current,
|
|
1336
|
-
)
|
|
1337
|
-
descriptors.append(current)
|
|
1338
|
-
initial = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
1339
|
-
if not stat.S_ISREG(initial.st_mode):
|
|
1340
|
-
raise LifecycleError(
|
|
1341
|
-
f"scratch path is not a regular file: {relative}"
|
|
1342
|
-
)
|
|
1343
|
-
file_descriptor = os.open(
|
|
1344
|
-
path.name,
|
|
1345
|
-
os.O_RDONLY | no_follow,
|
|
1346
|
-
dir_fd=current,
|
|
1347
|
-
)
|
|
1348
|
-
try:
|
|
1349
|
-
metadata = os.fstat(file_descriptor)
|
|
1350
|
-
if (
|
|
1351
|
-
not stat.S_ISREG(metadata.st_mode)
|
|
1352
|
-
or (metadata.st_dev, metadata.st_ino)
|
|
1353
|
-
!= (initial.st_dev, initial.st_ino)
|
|
1354
|
-
):
|
|
1355
|
-
raise LifecycleError(
|
|
1356
|
-
f"scratch path changed before inspection: {relative}"
|
|
1357
|
-
)
|
|
1358
|
-
digest = sha256()
|
|
1359
|
-
while chunk := os.read(file_descriptor, 128 * 1024):
|
|
1360
|
-
digest.update(chunk)
|
|
1361
|
-
finally:
|
|
1362
|
-
os.close(file_descriptor)
|
|
1363
|
-
return {
|
|
1364
|
-
"path": relative,
|
|
1365
|
-
"device": metadata.st_dev,
|
|
1366
|
-
"inode": metadata.st_ino,
|
|
1367
|
-
"size": metadata.st_size,
|
|
1368
|
-
"sha256": digest.hexdigest(),
|
|
1369
|
-
}
|
|
1370
|
-
except OSError as error:
|
|
1371
|
-
raise LifecycleError(f"scratch path changed before inspection: {relative}") from error
|
|
1372
|
-
finally:
|
|
1373
|
-
for descriptor in reversed(descriptors):
|
|
1374
|
-
os.close(descriptor)
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
def contained_untracked_identity(
|
|
1378
|
-
root_descriptor: int,
|
|
1379
|
-
relative: str,
|
|
1380
|
-
*,
|
|
1381
|
-
require_contained_symlink_target: bool = False,
|
|
1382
|
-
) -> dict[str, Any]:
|
|
1383
|
-
"""Read exact regular/symlink identity without following any symlink."""
|
|
1384
|
-
path = PurePosixPath(relative)
|
|
1385
|
-
if path.is_absolute() or not path.parts or any(
|
|
1386
|
-
part in {"", ".", ".."} for part in path.parts
|
|
1387
|
-
):
|
|
1388
|
-
raise LifecycleError(f"unsafe recovery path: {relative}")
|
|
1389
|
-
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1390
|
-
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1391
|
-
descriptors = []
|
|
1392
|
-
try:
|
|
1393
|
-
current = root_descriptor
|
|
1394
|
-
for component in path.parts[:-1]:
|
|
1395
|
-
current = os.open(
|
|
1396
|
-
component,
|
|
1397
|
-
directory_flags | no_follow,
|
|
1398
|
-
dir_fd=current,
|
|
1399
|
-
)
|
|
1400
|
-
descriptors.append(current)
|
|
1401
|
-
metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
1402
|
-
common = {
|
|
1403
|
-
"path": relative,
|
|
1404
|
-
"device": metadata.st_dev,
|
|
1405
|
-
"inode": metadata.st_ino,
|
|
1406
|
-
"size": metadata.st_size,
|
|
1407
|
-
}
|
|
1408
|
-
if stat.S_ISLNK(metadata.st_mode):
|
|
1409
|
-
target_text = os.readlink(path.name, dir_fd=current)
|
|
1410
|
-
target = target_text.encode(
|
|
1411
|
-
"utf-8", errors="surrogateescape"
|
|
1412
|
-
)
|
|
1413
|
-
identity = {
|
|
1414
|
-
**common,
|
|
1415
|
-
"kind": "symlink",
|
|
1416
|
-
"sha256": sha256(target).hexdigest(),
|
|
1417
|
-
}
|
|
1418
|
-
if require_contained_symlink_target:
|
|
1419
|
-
target_metadata = contained_symlink_target_identity(
|
|
1420
|
-
root_descriptor,
|
|
1421
|
-
relative,
|
|
1422
|
-
target_text,
|
|
1423
|
-
)
|
|
1424
|
-
identity.update({
|
|
1425
|
-
"targetDevice": target_metadata.st_dev,
|
|
1426
|
-
"targetInode": target_metadata.st_ino,
|
|
1427
|
-
})
|
|
1428
|
-
return identity
|
|
1429
|
-
if not stat.S_ISREG(metadata.st_mode):
|
|
1430
|
-
raise LifecycleError(f"unsupported recovery path type: {relative}")
|
|
1431
|
-
file_descriptor = os.open(
|
|
1432
|
-
path.name,
|
|
1433
|
-
os.O_RDONLY | no_follow,
|
|
1434
|
-
dir_fd=current,
|
|
1435
|
-
)
|
|
1436
|
-
try:
|
|
1437
|
-
opened = os.fstat(file_descriptor)
|
|
1438
|
-
if (opened.st_dev, opened.st_ino) != (
|
|
1439
|
-
metadata.st_dev, metadata.st_ino
|
|
1440
|
-
):
|
|
1441
|
-
raise LifecycleError(
|
|
1442
|
-
f"recovery path changed before inspection: {relative}"
|
|
1443
|
-
)
|
|
1444
|
-
digest = sha256()
|
|
1445
|
-
while chunk := os.read(file_descriptor, 128 * 1024):
|
|
1446
|
-
digest.update(chunk)
|
|
1447
|
-
finally:
|
|
1448
|
-
os.close(file_descriptor)
|
|
1449
|
-
return {**common, "kind": "regular", "sha256": digest.hexdigest()}
|
|
1450
|
-
except OSError as error:
|
|
1451
|
-
raise LifecycleError(
|
|
1452
|
-
f"recovery path changed before inspection: {relative}"
|
|
1453
|
-
) from error
|
|
1454
|
-
finally:
|
|
1455
|
-
for descriptor in reversed(descriptors):
|
|
1456
|
-
os.close(descriptor)
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
def contained_profile_scratch_identity(
|
|
1460
|
-
root_descriptor: int,
|
|
1461
|
-
relative: str,
|
|
1462
|
-
) -> dict[str, Any]:
|
|
1463
|
-
"""Freeze regular scratch unchanged, or one contained symlink and its target."""
|
|
1464
|
-
identity = contained_untracked_identity(
|
|
1465
|
-
root_descriptor,
|
|
1466
|
-
relative,
|
|
1467
|
-
require_contained_symlink_target=True,
|
|
1468
|
-
)
|
|
1469
|
-
if identity["kind"] == "regular":
|
|
1470
|
-
return {
|
|
1471
|
-
key: value
|
|
1472
|
-
for key, value in identity.items()
|
|
1473
|
-
if key != "kind"
|
|
1474
|
-
}
|
|
1475
|
-
return identity
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
def _relative_target_parts(
|
|
1479
|
-
base_parts: list[str],
|
|
1480
|
-
target: str,
|
|
1481
|
-
relative: str,
|
|
1482
|
-
) -> list[str]:
|
|
1483
|
-
target_path = PurePosixPath(target)
|
|
1484
|
-
if target_path.is_absolute():
|
|
1485
|
-
raise LifecycleError(f"scratch symlink target is absolute: {relative}")
|
|
1486
|
-
resolved = list(base_parts)
|
|
1487
|
-
for component in target_path.parts:
|
|
1488
|
-
if component in {"", "."}:
|
|
1489
|
-
continue
|
|
1490
|
-
if component == "..":
|
|
1491
|
-
if not resolved:
|
|
1492
|
-
raise LifecycleError(
|
|
1493
|
-
f"scratch symlink target escapes worktree: {relative}"
|
|
1494
|
-
)
|
|
1495
|
-
resolved.pop()
|
|
1496
|
-
continue
|
|
1497
|
-
resolved.append(component)
|
|
1498
|
-
return resolved
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
def contained_symlink_target_identity(
|
|
1502
|
-
root_descriptor: int,
|
|
1503
|
-
relative: str,
|
|
1504
|
-
target: str,
|
|
1505
|
-
) -> os.stat_result:
|
|
1506
|
-
"""Resolve one symlink target beneath the open root, rejecting every escape."""
|
|
1507
|
-
link = PurePosixPath(relative)
|
|
1508
|
-
pending = _relative_target_parts(list(link.parts[:-1]), target, relative)
|
|
1509
|
-
resolved: list[str] = []
|
|
1510
|
-
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1511
|
-
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1512
|
-
symlink_count = 0
|
|
1513
|
-
try:
|
|
1514
|
-
if not pending:
|
|
1515
|
-
return os.fstat(root_descriptor)
|
|
1516
|
-
while pending:
|
|
1517
|
-
component = pending.pop(0)
|
|
1518
|
-
descriptors = []
|
|
1519
|
-
try:
|
|
1520
|
-
parent = root_descriptor
|
|
1521
|
-
for ancestor in resolved:
|
|
1522
|
-
parent = os.open(
|
|
1523
|
-
ancestor,
|
|
1524
|
-
directory_flags | no_follow,
|
|
1525
|
-
dir_fd=parent,
|
|
1526
|
-
)
|
|
1527
|
-
descriptors.append(parent)
|
|
1528
|
-
metadata = os.stat(
|
|
1529
|
-
component,
|
|
1530
|
-
dir_fd=parent,
|
|
1531
|
-
follow_symlinks=False,
|
|
1532
|
-
)
|
|
1533
|
-
if stat.S_ISLNK(metadata.st_mode):
|
|
1534
|
-
symlink_count += 1
|
|
1535
|
-
if symlink_count > 40:
|
|
1536
|
-
raise OSError("too many symbolic links")
|
|
1537
|
-
nested_target = os.readlink(component, dir_fd=parent)
|
|
1538
|
-
pending = _relative_target_parts(
|
|
1539
|
-
resolved,
|
|
1540
|
-
nested_target,
|
|
1541
|
-
relative,
|
|
1542
|
-
) + pending
|
|
1543
|
-
resolved = []
|
|
1544
|
-
if not pending:
|
|
1545
|
-
return os.fstat(root_descriptor)
|
|
1546
|
-
continue
|
|
1547
|
-
if pending and not stat.S_ISDIR(metadata.st_mode):
|
|
1548
|
-
raise OSError("symlink target parent is not a directory")
|
|
1549
|
-
resolved.append(component)
|
|
1550
|
-
if not pending:
|
|
1551
|
-
return metadata
|
|
1552
|
-
finally:
|
|
1553
|
-
for descriptor in reversed(descriptors):
|
|
1554
|
-
os.close(descriptor)
|
|
1555
|
-
except LifecycleError:
|
|
1556
|
-
raise
|
|
1557
|
-
except OSError as error:
|
|
1558
|
-
raise LifecycleError(
|
|
1559
|
-
f"scratch symlink target is dangling or changed: {relative}"
|
|
1560
|
-
) from error
|
|
1561
|
-
raise LifecycleError(f"scratch symlink target is dangling or changed: {relative}")
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
def remove_contained_untracked(
|
|
1565
|
-
root_descriptor: int,
|
|
1566
|
-
expected_identity: dict[str, Any],
|
|
1567
|
-
*,
|
|
1568
|
-
require_contained_symlink_target: bool = False,
|
|
1569
|
-
) -> None:
|
|
1570
|
-
"""Unlink one frozen regular file or symlink if its identity still matches."""
|
|
1571
|
-
relative = expected_identity.get("path")
|
|
1572
|
-
if not isinstance(relative, str):
|
|
1573
|
-
raise LifecycleError("recovery path evidence is incoherent")
|
|
1574
|
-
current = contained_untracked_identity(
|
|
1575
|
-
root_descriptor,
|
|
1576
|
-
relative,
|
|
1577
|
-
require_contained_symlink_target=require_contained_symlink_target,
|
|
1578
|
-
)
|
|
1579
|
-
if current != expected_identity:
|
|
1580
|
-
raise LifecycleError(f"recovery path identity changed: {relative}")
|
|
1581
|
-
path = PurePosixPath(relative)
|
|
1582
|
-
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1583
|
-
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1584
|
-
descriptors = []
|
|
1585
|
-
try:
|
|
1586
|
-
parent = root_descriptor
|
|
1587
|
-
for component in path.parts[:-1]:
|
|
1588
|
-
parent = os.open(
|
|
1589
|
-
component,
|
|
1590
|
-
directory_flags | no_follow,
|
|
1591
|
-
dir_fd=parent,
|
|
1592
|
-
)
|
|
1593
|
-
descriptors.append(parent)
|
|
1594
|
-
latest = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
|
|
1595
|
-
if (latest.st_dev, latest.st_ino) != (
|
|
1596
|
-
expected_identity["device"], expected_identity["inode"]
|
|
1597
|
-
):
|
|
1598
|
-
raise LifecycleError(f"recovery path changed before removal: {relative}")
|
|
1599
|
-
os.unlink(path.name, dir_fd=parent)
|
|
1600
|
-
except OSError as error:
|
|
1601
|
-
raise LifecycleError(f"recovery path changed before removal: {relative}") from error
|
|
1602
|
-
finally:
|
|
1603
|
-
for descriptor in reversed(descriptors):
|
|
1604
|
-
os.close(descriptor)
|
|
1605
223
|
|
|
1606
224
|
|
|
1607
225
|
@dataclass(frozen=True)
|
|
@@ -1637,6 +255,7 @@ class SweepFactRow:
|
|
|
1637
255
|
merged_into_main: bool
|
|
1638
256
|
last_commit_age_seconds: int
|
|
1639
257
|
cleanup: CleanupFacts | None = None
|
|
258
|
+
cleanup_error: str = ""
|
|
1640
259
|
|
|
1641
260
|
|
|
1642
261
|
@dataclass(frozen=True)
|
|
@@ -1670,6 +289,23 @@ def _worktree_branches(main: Path) -> tuple[dict[str, Path], tuple[Path, ...]]:
|
|
|
1670
289
|
return linked, tuple(detached)
|
|
1671
290
|
|
|
1672
291
|
|
|
292
|
+
def _cleanup_facts_or_error(
|
|
293
|
+
main: Path,
|
|
294
|
+
path: Path,
|
|
295
|
+
*,
|
|
296
|
+
merge_target: str,
|
|
297
|
+
pr_state: str,
|
|
298
|
+
) -> tuple[CleanupFacts | None, str]:
|
|
299
|
+
"""A single unreadable worktree must not abort the read-only inventory."""
|
|
300
|
+
try:
|
|
301
|
+
facts = collect_cleanup_facts(
|
|
302
|
+
main, path, merge_target=merge_target, pr_state=pr_state
|
|
303
|
+
)
|
|
304
|
+
except LifecycleError as error:
|
|
305
|
+
return None, str(error)
|
|
306
|
+
return facts, ""
|
|
307
|
+
|
|
308
|
+
|
|
1673
309
|
def collect_sweep_facts(
|
|
1674
310
|
profile: WorktreeProfile,
|
|
1675
311
|
main: Path,
|
|
@@ -1702,18 +338,21 @@ def collect_sweep_facts(
|
|
|
1702
338
|
cwd=main,
|
|
1703
339
|
check=False,
|
|
1704
340
|
).returncode == 0
|
|
341
|
+
cleanup, cleanup_error = (
|
|
342
|
+
_cleanup_facts_or_error(
|
|
343
|
+
main, path, merge_target=main_branch, pr_state=pr_state
|
|
344
|
+
)
|
|
345
|
+
if path is not None
|
|
346
|
+
else (None, "")
|
|
347
|
+
)
|
|
1705
348
|
rows.append(SweepFactRow(
|
|
1706
349
|
path=path,
|
|
1707
350
|
branch=branch,
|
|
1708
351
|
pr_state=pr_state,
|
|
1709
352
|
merged_into_main=merged,
|
|
1710
353
|
last_commit_age_seconds=max(0, timestamp - int(commit_time)),
|
|
1711
|
-
cleanup=
|
|
1712
|
-
|
|
1713
|
-
path,
|
|
1714
|
-
merge_target=main_branch,
|
|
1715
|
-
pr_state=pr_state,
|
|
1716
|
-
) if path is not None else None,
|
|
354
|
+
cleanup=cleanup,
|
|
355
|
+
cleanup_error=cleanup_error,
|
|
1717
356
|
))
|
|
1718
357
|
for path in detached:
|
|
1719
358
|
commit_time = run(
|
|
@@ -1759,7 +398,7 @@ def classify_sweep(profile: WorktreeProfile, facts: SweepFacts) -> SweepReport:
|
|
|
1759
398
|
scratch = assessment.scratch_files
|
|
1760
399
|
assumptions = assessment.assumptions
|
|
1761
400
|
elif fact.path is not None:
|
|
1762
|
-
reasons = ("detached or unreadable branch",)
|
|
401
|
+
reasons = (fact.cleanup_error or "detached or unreadable branch",)
|
|
1763
402
|
scratch = ()
|
|
1764
403
|
assumptions = ""
|
|
1765
404
|
else:
|