@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
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stateless teardown classification.
|
|
3
|
+
|
|
4
|
+
Teardown authority comes from the repository's current state, read at the
|
|
5
|
+
moment of action, and from nothing else. Four rules decide it:
|
|
6
|
+
|
|
7
|
+
1. A tracked change (porcelain v2 records `1` and `2`) blocks.
|
|
8
|
+
2. An unmerged path (record `u`) is tracked work and blocks.
|
|
9
|
+
3. An untracked, non-ignored file (record `?`) blocks with a bounded report:
|
|
10
|
+
the exact file count plus the top directories, never a path dump.
|
|
11
|
+
4. An ignored entry (record `!`) is Scratch and is deletable — with one
|
|
12
|
+
hardcoded carve-out, `.env*` by basename glob, which is deletable only when
|
|
13
|
+
it is byte-identical to its counterpart at the same relative path in the
|
|
14
|
+
main checkout, both opened no-follow.
|
|
15
|
+
|
|
16
|
+
An ignored symlink is deletable only when its target resolves inside the
|
|
17
|
+
assessed worktree; the link itself is unlinked and never followed. Absolute
|
|
18
|
+
targets, escaping targets, dangling targets, and a target that changed between
|
|
19
|
+
assessment and action are hard stops that name the link. The containment check
|
|
20
|
+
applies to the ignored entries git itself reports: git collapses an ignored
|
|
21
|
+
directory into a single record, and the contained recursive removal below
|
|
22
|
+
never follows a symlink, so a pnpm-style symlink farm inside such a directory
|
|
23
|
+
is removable with zero configuration.
|
|
24
|
+
|
|
25
|
+
One assessment object serves both surfaces: `assess()` returns it, a preview
|
|
26
|
+
renders it with `render_report()`, and the action consumes exactly that object
|
|
27
|
+
in `remove_scratch()`. There is no second formatter and no persisted evidence —
|
|
28
|
+
idempotency comes from re-running the assessment, not from a journal.
|
|
29
|
+
|
|
30
|
+
Deletion policy has exactly one configuration surface, the ignore mechanism:
|
|
31
|
+
this module reads no consumer-profile pattern list.
|
|
32
|
+
|
|
33
|
+
## Residual risks — accepted deliberately
|
|
34
|
+
|
|
35
|
+
- Between assessment and deletion a file could in principle be replaced.
|
|
36
|
+
- A valuable file a consumer keeps gitignored outside `.env*` is deletable at
|
|
37
|
+
teardown.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import fnmatch
|
|
43
|
+
import io
|
|
44
|
+
import os
|
|
45
|
+
import stat
|
|
46
|
+
import subprocess
|
|
47
|
+
from collections import Counter
|
|
48
|
+
from contextlib import contextmanager
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from pathlib import Path, PurePosixPath
|
|
51
|
+
|
|
52
|
+
TOP_DIRECTORY_LIMIT = 5
|
|
53
|
+
EXAMPLE_LIMIT = 5
|
|
54
|
+
ENV_BASENAME_GLOB = ".env*"
|
|
55
|
+
|
|
56
|
+
RULE_TRACKED = "tracked-change"
|
|
57
|
+
RULE_UNMERGED = "unmerged-path"
|
|
58
|
+
RULE_UNTRACKED = "untracked-files"
|
|
59
|
+
RULE_ENV = "env-file"
|
|
60
|
+
RULE_SYMLINK = "ignored-symlink"
|
|
61
|
+
|
|
62
|
+
_READ_CHUNK = 128 * 1024
|
|
63
|
+
_ITEM_LIMIT = 200
|
|
64
|
+
_DIRECTORY_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
65
|
+
_FILE_FLAGS = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
66
|
+
# Path fields per porcelain v2 record type; the path is the field after them.
|
|
67
|
+
_LEADING_FIELDS = {"1": 8, "2": 9, "u": 10}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ClassificationError(RuntimeError):
|
|
71
|
+
"""Teardown cannot be classified or acted on safely."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Scratch:
|
|
76
|
+
"""One ignored entry cleared for deletion."""
|
|
77
|
+
|
|
78
|
+
path: str
|
|
79
|
+
kind: str # "file" | "directory" | "symlink"
|
|
80
|
+
link_target: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Block:
|
|
85
|
+
"""One fired rule: what it saw, capped examples, and the fix."""
|
|
86
|
+
|
|
87
|
+
rule: str
|
|
88
|
+
summary: str
|
|
89
|
+
items: tuple[str, ...]
|
|
90
|
+
item_count: int
|
|
91
|
+
fix: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Assessment:
|
|
96
|
+
"""The single report object shared by preview and action."""
|
|
97
|
+
|
|
98
|
+
worktree: str
|
|
99
|
+
main_checkout: str
|
|
100
|
+
root_device: int
|
|
101
|
+
root_inode: int
|
|
102
|
+
scratch: tuple[Scratch, ...]
|
|
103
|
+
blocks: tuple[Block, ...]
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def removable(self) -> bool:
|
|
107
|
+
return not self.blocks
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def assess(worktree, main_checkout) -> Assessment:
|
|
111
|
+
"""Classify the worktree's current state into scratch plus blocking rules."""
|
|
112
|
+
worktree = Path(worktree)
|
|
113
|
+
main_checkout = Path(main_checkout)
|
|
114
|
+
metadata = os.lstat(worktree)
|
|
115
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
116
|
+
raise ClassificationError(f"worktree is not a directory: {worktree}")
|
|
117
|
+
buckets: dict[str, list[str]] = {"1": [], "u": [], "?": [], "!": []}
|
|
118
|
+
for kind, paths in _status_records(worktree):
|
|
119
|
+
buckets["1" if kind == "2" else kind].extend(paths)
|
|
120
|
+
scratch, env_offenders, link_offenders = _classify_ignored(
|
|
121
|
+
worktree, main_checkout, buckets["!"]
|
|
122
|
+
)
|
|
123
|
+
blocks = [
|
|
124
|
+
block
|
|
125
|
+
for block in (
|
|
126
|
+
_named_block(RULE_TRACKED, buckets["1"], "tracked change", "commit or stash"),
|
|
127
|
+
_named_block(RULE_UNMERGED, buckets["u"], "unmerged path", "resolve and commit"),
|
|
128
|
+
_untracked_block(buckets["?"]),
|
|
129
|
+
_named_block(RULE_ENV, env_offenders, ".env* file", _ENV_FIX),
|
|
130
|
+
_named_block(RULE_SYMLINK, link_offenders, "ignored symlink", _SYMLINK_FIX),
|
|
131
|
+
)
|
|
132
|
+
if block is not None
|
|
133
|
+
]
|
|
134
|
+
return Assessment(
|
|
135
|
+
str(worktree),
|
|
136
|
+
str(main_checkout),
|
|
137
|
+
metadata.st_dev,
|
|
138
|
+
metadata.st_ino,
|
|
139
|
+
tuple(sorted(scratch, key=lambda entry: entry.path)),
|
|
140
|
+
tuple(blocks),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_ENV_FIX = (
|
|
145
|
+
"copy the named .env* file out or make it identical to the main checkout copy"
|
|
146
|
+
)
|
|
147
|
+
_SYMLINK_FIX = "remove the named symlink or point it inside the worktree"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _status_records(worktree: Path):
|
|
151
|
+
"""Read `git status --porcelain=v2 -z` and split it back into records."""
|
|
152
|
+
# `--ignored=matching` keeps an ignored directory one record instead of
|
|
153
|
+
# thousands; `-z` is required because paths carry spaces, quotes and
|
|
154
|
+
# newlines; `--untracked-files=all` makes the untracked count exact.
|
|
155
|
+
command = ["git", "-C", str(worktree), "status", "--porcelain=v2", "-z"]
|
|
156
|
+
command += ["--untracked-files=all", "--ignored=matching"]
|
|
157
|
+
completed = subprocess.run(command, capture_output=True)
|
|
158
|
+
if completed.returncode != 0:
|
|
159
|
+
detail = completed.stderr.decode("utf-8", "replace").strip()
|
|
160
|
+
raise ClassificationError(f"git status failed in {worktree}: {detail}")
|
|
161
|
+
return _parse_status(completed.stdout)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _parse_status(payload: bytes):
|
|
165
|
+
"""Yield (record type, paths) — a rename carries two NUL-separated paths."""
|
|
166
|
+
chunks = payload.split(b"\0")
|
|
167
|
+
records = []
|
|
168
|
+
index = 0
|
|
169
|
+
while index < len(chunks):
|
|
170
|
+
raw = chunks[index]
|
|
171
|
+
index += 1
|
|
172
|
+
if not raw:
|
|
173
|
+
continue
|
|
174
|
+
kind = chr(raw[0])
|
|
175
|
+
if kind in ("?", "!"):
|
|
176
|
+
records.append((kind, [os.fsdecode(raw[2:])]))
|
|
177
|
+
continue
|
|
178
|
+
if kind not in _LEADING_FIELDS:
|
|
179
|
+
raise ClassificationError(f"unsupported git status record: {kind!r}")
|
|
180
|
+
fields = raw.split(b" ", _LEADING_FIELDS[kind])
|
|
181
|
+
if len(fields) != _LEADING_FIELDS[kind] + 1:
|
|
182
|
+
raise ClassificationError(f"malformed git status record: {raw!r}")
|
|
183
|
+
paths = [os.fsdecode(fields[-1])]
|
|
184
|
+
if kind == "2":
|
|
185
|
+
if index >= len(chunks):
|
|
186
|
+
raise ClassificationError("truncated rename record: origin path missing")
|
|
187
|
+
paths.append(os.fsdecode(chunks[index]))
|
|
188
|
+
index += 1
|
|
189
|
+
records.append((kind, paths))
|
|
190
|
+
return records
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _classify_ignored(worktree: Path, main_checkout: Path, ignored: list[str]):
|
|
194
|
+
"""Split ignored entries into scratch, `.env*` offenders and link offenders."""
|
|
195
|
+
scratch: list[Scratch] = []
|
|
196
|
+
env_offenders: list[str] = []
|
|
197
|
+
link_offenders: list[str] = []
|
|
198
|
+
for entry in ignored:
|
|
199
|
+
relative = entry[:-1] if entry.endswith("/") else entry
|
|
200
|
+
metadata = _lstat(worktree, relative)
|
|
201
|
+
if _is_env(relative):
|
|
202
|
+
problem = _env_problem(worktree, main_checkout, relative, metadata)
|
|
203
|
+
if problem is None:
|
|
204
|
+
scratch.append(Scratch(relative, "file"))
|
|
205
|
+
else:
|
|
206
|
+
env_offenders.append(f"{relative} — {problem}")
|
|
207
|
+
elif stat.S_ISLNK(metadata.st_mode):
|
|
208
|
+
problem, target = _link_problem(worktree, relative)
|
|
209
|
+
if problem is None:
|
|
210
|
+
scratch.append(Scratch(relative, "symlink", target))
|
|
211
|
+
else:
|
|
212
|
+
link_offenders.append(f"{relative} — {problem}")
|
|
213
|
+
elif stat.S_ISDIR(metadata.st_mode):
|
|
214
|
+
nested = _nested_env_problems(worktree, main_checkout, relative)
|
|
215
|
+
env_offenders.extend(nested)
|
|
216
|
+
if not nested:
|
|
217
|
+
scratch.append(Scratch(relative, "directory"))
|
|
218
|
+
else:
|
|
219
|
+
scratch.append(Scratch(relative, "file"))
|
|
220
|
+
return scratch, env_offenders, link_offenders
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _lstat(root: Path, relative: str):
|
|
224
|
+
try:
|
|
225
|
+
return os.lstat(root / relative)
|
|
226
|
+
except OSError as error:
|
|
227
|
+
raise ClassificationError(f"cannot read {relative}: {error}") from error
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _is_env(relative: str) -> bool:
|
|
231
|
+
return fnmatch.fnmatchcase(PurePosixPath(relative).name, ENV_BASENAME_GLOB)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _nested_env_problems(worktree: Path, main_checkout: Path, relative: str):
|
|
235
|
+
"""Find `.env*` files inside an ignored directory git reported as one entry."""
|
|
236
|
+
problems = []
|
|
237
|
+
base = worktree / relative
|
|
238
|
+
for directory, _subdirectories, names in os.walk(base, followlinks=False):
|
|
239
|
+
for name in names:
|
|
240
|
+
if not fnmatch.fnmatchcase(name, ENV_BASENAME_GLOB):
|
|
241
|
+
continue
|
|
242
|
+
nested = PurePosixPath(relative) / os.path.relpath(
|
|
243
|
+
os.path.join(directory, name), base
|
|
244
|
+
)
|
|
245
|
+
problem = _env_problem(
|
|
246
|
+
worktree, main_checkout, str(nested), _lstat(worktree, str(nested))
|
|
247
|
+
)
|
|
248
|
+
if problem is not None:
|
|
249
|
+
problems.append(f"{nested} — {problem}")
|
|
250
|
+
return sorted(problems)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _env_problem(worktree: Path, main_checkout: Path, relative: str, metadata):
|
|
254
|
+
"""Compare one `.env*` file with its main-checkout counterpart, no-follow."""
|
|
255
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
256
|
+
return "not a regular file in the worktree"
|
|
257
|
+
try:
|
|
258
|
+
with _opened_regular(worktree, relative) as left:
|
|
259
|
+
with _opened_regular(main_checkout, relative) as right:
|
|
260
|
+
if left is None or right is None:
|
|
261
|
+
return "absent from the main checkout, or not a regular file there"
|
|
262
|
+
return None if _same_bytes(left, right) else "differs from the main checkout copy"
|
|
263
|
+
except OSError as error:
|
|
264
|
+
raise ClassificationError(f"cannot compare {relative}: {error}") from error
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@contextmanager
|
|
268
|
+
def _opened_regular(root: Path, relative: str):
|
|
269
|
+
"""Open a regular file below root, following nothing; yield None if it is
|
|
270
|
+
missing, unreachable, or not a regular file — all three block a `.env*`."""
|
|
271
|
+
stream = None
|
|
272
|
+
try:
|
|
273
|
+
try:
|
|
274
|
+
with _opened_directory(root) as root_descriptor:
|
|
275
|
+
with _contained_parent(root_descriptor, relative) as (parent, name):
|
|
276
|
+
descriptor = os.open(name, _FILE_FLAGS, dir_fd=parent)
|
|
277
|
+
if stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
278
|
+
stream = io.open(descriptor, "rb")
|
|
279
|
+
else:
|
|
280
|
+
os.close(descriptor)
|
|
281
|
+
except OSError:
|
|
282
|
+
stream = None
|
|
283
|
+
yield stream
|
|
284
|
+
finally:
|
|
285
|
+
if stream is not None:
|
|
286
|
+
stream.close()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _same_bytes(left, right) -> bool:
|
|
290
|
+
if os.fstat(left.fileno()).st_size != os.fstat(right.fileno()).st_size:
|
|
291
|
+
return False
|
|
292
|
+
while True:
|
|
293
|
+
chunk = left.read(_READ_CHUNK)
|
|
294
|
+
if chunk != right.read(_READ_CHUNK):
|
|
295
|
+
return False
|
|
296
|
+
if not chunk:
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _link_problem(worktree: Path, relative: str):
|
|
301
|
+
"""Decide whether an ignored symlink stays contained; never follow it."""
|
|
302
|
+
link = worktree / relative
|
|
303
|
+
target = os.readlink(link)
|
|
304
|
+
if os.path.isabs(target):
|
|
305
|
+
return "absolute target", target
|
|
306
|
+
if not os.path.exists(link):
|
|
307
|
+
return "dangling target", target
|
|
308
|
+
inside = os.path.realpath(worktree)
|
|
309
|
+
resolved = os.path.realpath(link)
|
|
310
|
+
if resolved != inside and not resolved.startswith(inside + os.sep):
|
|
311
|
+
return "target escapes the worktree", target
|
|
312
|
+
return None, target
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _named_block(rule: str, items, noun: str, fix: str):
|
|
316
|
+
if not items:
|
|
317
|
+
return None
|
|
318
|
+
unique = sorted(dict.fromkeys(items))
|
|
319
|
+
one = len(unique) == 1
|
|
320
|
+
return Block(
|
|
321
|
+
rule,
|
|
322
|
+
f"{len(unique)} {noun}{'' if one else 's'} {'blocks' if one else 'block'} teardown",
|
|
323
|
+
tuple(_shorten(item) for item in unique[:EXAMPLE_LIMIT]),
|
|
324
|
+
len(unique),
|
|
325
|
+
f"{fix}, then run teardown again",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _untracked_block(paths):
|
|
330
|
+
if not paths:
|
|
331
|
+
return None
|
|
332
|
+
counts = Counter(_parent(path) for path in paths)
|
|
333
|
+
top = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))[:TOP_DIRECTORY_LIMIT]
|
|
334
|
+
files = _count(len(paths), "untracked file")
|
|
335
|
+
return Block(
|
|
336
|
+
RULE_UNTRACKED,
|
|
337
|
+
f"{files} in {_count(len(counts), 'directory', 'directories')} "
|
|
338
|
+
f"{'is' if len(paths) == 1 else 'are'} not ignored",
|
|
339
|
+
tuple(
|
|
340
|
+
f"{_shorten(directory)} ({_count(count, 'file')})"
|
|
341
|
+
for directory, count in top
|
|
342
|
+
),
|
|
343
|
+
len(counts),
|
|
344
|
+
"ignore or remove the untracked files, then run teardown again",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _count(total: int, noun: str, plural: str | None = None) -> str:
|
|
349
|
+
return f"{total} {noun}" if total == 1 else f"{total} {plural or noun + 's'}"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _parent(path: str) -> str:
|
|
353
|
+
parent = str(PurePosixPath(path).parent)
|
|
354
|
+
return "./" if parent == "." else f"{parent}/"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _shorten(text: str) -> str:
|
|
358
|
+
"""Truncate one reported item and make it printable.
|
|
359
|
+
|
|
360
|
+
Paths arrive decoded with surrogateescape, so a non-UTF-8 filename would
|
|
361
|
+
otherwise raise while the refusal is being printed — a report that cannot
|
|
362
|
+
be shown names nothing, which is the failure this module replaces.
|
|
363
|
+
"""
|
|
364
|
+
printable = text.encode("utf-8", "replace").decode("utf-8")
|
|
365
|
+
return printable if len(printable) <= _ITEM_LIMIT else f"{printable[:_ITEM_LIMIT]}…"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def render_report(assessment: Assessment) -> str:
|
|
369
|
+
"""Render the one assessment object — always bounded, never a path dump."""
|
|
370
|
+
if assessment.removable:
|
|
371
|
+
preview = [entry.path for entry in assessment.scratch[:EXAMPLE_LIMIT]]
|
|
372
|
+
lines = [f"Teardown is clear: {len(assessment.scratch)} scratch entries are deletable."]
|
|
373
|
+
lines.extend(f" - {_shorten(path)}" for path in preview)
|
|
374
|
+
if len(assessment.scratch) > len(preview):
|
|
375
|
+
lines.append(f" … {len(assessment.scratch) - len(preview)} more (not listed)")
|
|
376
|
+
return "\n".join(lines)
|
|
377
|
+
lines = []
|
|
378
|
+
for block in assessment.blocks:
|
|
379
|
+
lines.append(f"[{block.rule}] {block.summary}:")
|
|
380
|
+
lines.extend(f" - {item}" for item in block.items)
|
|
381
|
+
if block.item_count > len(block.items):
|
|
382
|
+
lines.append(f" … {block.item_count - len(block.items)} more (not listed)")
|
|
383
|
+
lines.append(f" fix: {block.fix}")
|
|
384
|
+
return "\n".join(lines)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def remove_scratch(assessment: Assessment) -> tuple[str, ...]:
|
|
388
|
+
"""Delete every entry the given assessment cleared — the action's only input."""
|
|
389
|
+
if not assessment.removable:
|
|
390
|
+
raise ClassificationError(
|
|
391
|
+
"teardown is blocked:\n" + render_report(assessment)
|
|
392
|
+
)
|
|
393
|
+
removed = []
|
|
394
|
+
with _verified_root(assessment) as root_descriptor:
|
|
395
|
+
for entry in assessment.scratch:
|
|
396
|
+
_remove_entry(root_descriptor, entry)
|
|
397
|
+
removed.append(entry.path)
|
|
398
|
+
return tuple(removed)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@contextmanager
|
|
402
|
+
def _opened_directory(path: Path):
|
|
403
|
+
descriptor = os.open(path, _DIRECTORY_FLAGS)
|
|
404
|
+
try:
|
|
405
|
+
yield descriptor
|
|
406
|
+
finally:
|
|
407
|
+
os.close(descriptor)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@contextmanager
|
|
411
|
+
def _verified_root(assessment: Assessment):
|
|
412
|
+
"""Re-open the assessed root no-follow and re-check its identity."""
|
|
413
|
+
try:
|
|
414
|
+
with _opened_directory(Path(assessment.worktree)) as descriptor:
|
|
415
|
+
metadata = os.fstat(descriptor)
|
|
416
|
+
if (metadata.st_dev, metadata.st_ino) != (
|
|
417
|
+
assessment.root_device,
|
|
418
|
+
assessment.root_inode,
|
|
419
|
+
):
|
|
420
|
+
raise ClassificationError("worktree root changed before removal")
|
|
421
|
+
yield descriptor
|
|
422
|
+
except OSError as error:
|
|
423
|
+
raise ClassificationError("worktree root changed before removal") from error
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@contextmanager
|
|
427
|
+
def _contained_parent(root_descriptor: int, relative: str):
|
|
428
|
+
"""Walk to the entry's parent directory without following any symlink."""
|
|
429
|
+
parts = PurePosixPath(relative).parts
|
|
430
|
+
if PurePosixPath(relative).is_absolute() or not parts or any(
|
|
431
|
+
part in {"", ".", ".."} for part in parts
|
|
432
|
+
):
|
|
433
|
+
raise ClassificationError(f"unsafe scratch path: {relative}")
|
|
434
|
+
opened = []
|
|
435
|
+
current = root_descriptor
|
|
436
|
+
try:
|
|
437
|
+
for component in parts[:-1]:
|
|
438
|
+
current = os.open(component, _DIRECTORY_FLAGS, dir_fd=current)
|
|
439
|
+
opened.append(current)
|
|
440
|
+
yield current, parts[-1]
|
|
441
|
+
finally:
|
|
442
|
+
for descriptor in reversed(opened):
|
|
443
|
+
os.close(descriptor)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _remove_entry(root_descriptor: int, entry: Scratch) -> None:
|
|
447
|
+
"""Delete one assessed entry, re-checking its kind immediately before."""
|
|
448
|
+
try:
|
|
449
|
+
with _contained_parent(root_descriptor, entry.path) as (parent, name):
|
|
450
|
+
metadata = os.lstat(name, dir_fd=parent)
|
|
451
|
+
if entry.kind == "symlink":
|
|
452
|
+
if not stat.S_ISLNK(metadata.st_mode):
|
|
453
|
+
raise ClassificationError(f"scratch entry changed: {entry.path}")
|
|
454
|
+
if os.readlink(name, dir_fd=parent) != entry.link_target:
|
|
455
|
+
raise ClassificationError(f"symlink target changed: {entry.path}")
|
|
456
|
+
os.unlink(name, dir_fd=parent)
|
|
457
|
+
elif entry.kind == "directory":
|
|
458
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
459
|
+
raise ClassificationError(f"scratch entry changed: {entry.path}")
|
|
460
|
+
_remove_tree(parent, name)
|
|
461
|
+
else:
|
|
462
|
+
if stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
|
463
|
+
raise ClassificationError(f"scratch entry changed: {entry.path}")
|
|
464
|
+
os.unlink(name, dir_fd=parent)
|
|
465
|
+
except OSError as error:
|
|
466
|
+
raise ClassificationError(f"scratch entry changed: {entry.path}") from error
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _remove_tree(parent_descriptor: int, name: str) -> None:
|
|
470
|
+
"""Remove a directory tree contained by dir_fd walking, following nothing."""
|
|
471
|
+
descriptor = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_descriptor)
|
|
472
|
+
try:
|
|
473
|
+
for entry in os.scandir(descriptor):
|
|
474
|
+
if entry.is_dir(follow_symlinks=False):
|
|
475
|
+
_remove_tree(descriptor, entry.name)
|
|
476
|
+
else:
|
|
477
|
+
os.unlink(entry.name, dir_fd=descriptor)
|
|
478
|
+
finally:
|
|
479
|
+
os.close(descriptor)
|
|
480
|
+
os.rmdir(name, dir_fd=parent_descriptor)
|
|
@@ -9,17 +9,15 @@ import json
|
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from classify import ClassificationError, remove_scratch, render_report
|
|
12
13
|
from core import (
|
|
13
14
|
LifecycleError,
|
|
14
|
-
bind_cleanup_scratch_evidence,
|
|
15
15
|
classify_cleanup,
|
|
16
16
|
collect_cleanup_facts,
|
|
17
17
|
collect_sweep,
|
|
18
18
|
load_profile,
|
|
19
19
|
main_worktree,
|
|
20
|
-
remove_authorized_scratch,
|
|
21
20
|
run,
|
|
22
|
-
verified_worktree_root,
|
|
23
21
|
)
|
|
24
22
|
|
|
25
23
|
|
|
@@ -75,18 +73,7 @@ def collect_assessment(profile, main: Path, worktree: Path, gh_command: str):
|
|
|
75
73
|
worktree,
|
|
76
74
|
)
|
|
77
75
|
state = pr_state(gh_command, main, facts.branch)
|
|
78
|
-
|
|
79
|
-
try:
|
|
80
|
-
return bind_cleanup_scratch_evidence(
|
|
81
|
-
profile,
|
|
82
|
-
assessment,
|
|
83
|
-
require_generator_evidence=True,
|
|
84
|
-
)
|
|
85
|
-
except LifecycleError as error:
|
|
86
|
-
return replace(
|
|
87
|
-
assessment,
|
|
88
|
-
reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
|
|
89
|
-
)
|
|
76
|
+
return classify_cleanup(profile, replace(facts, pr_state=state))
|
|
90
77
|
|
|
91
78
|
|
|
92
79
|
def execute(args: argparse.Namespace) -> dict:
|
|
@@ -106,6 +93,7 @@ def execute(args: argparse.Namespace) -> dict:
|
|
|
106
93
|
"reasons": list(assessment.reasons),
|
|
107
94
|
"assumptions": assessment.assumptions,
|
|
108
95
|
"scratchFiles": list(assessment.scratch_files),
|
|
96
|
+
"classification": render_report(assessment.classification),
|
|
109
97
|
"removed": False,
|
|
110
98
|
}
|
|
111
99
|
if not args.remove:
|
|
@@ -119,22 +107,14 @@ def execute(args: argparse.Namespace) -> dict:
|
|
|
119
107
|
latest.branch != assessment.branch
|
|
120
108
|
or latest.scratch_files != assessment.scratch_files
|
|
121
109
|
or latest.assumptions != assessment.assumptions
|
|
122
|
-
or latest.root_device
|
|
123
|
-
|
|
124
|
-
or latest.scratch_evidence != assessment.scratch_evidence
|
|
110
|
+
or (latest.classification.root_device, latest.classification.root_inode)
|
|
111
|
+
!= (assessment.classification.root_device, assessment.classification.root_inode)
|
|
125
112
|
):
|
|
126
113
|
raise LifecycleError("cleanup changed before removal: inventory no longer matches preview")
|
|
127
|
-
|
|
128
|
-
latest.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
) as root_descriptor:
|
|
132
|
-
remove_authorized_scratch(
|
|
133
|
-
profile,
|
|
134
|
-
root_descriptor,
|
|
135
|
-
latest.scratch_files,
|
|
136
|
-
assessment.scratch_evidence,
|
|
137
|
-
)
|
|
114
|
+
try:
|
|
115
|
+
remove_scratch(latest.classification)
|
|
116
|
+
except ClassificationError as error:
|
|
117
|
+
raise LifecycleError(f"cleanup changed before removal: {error}") from error
|
|
138
118
|
run(["git", "worktree", "remove", str(latest.worktree)], cwd=main)
|
|
139
119
|
run(["git", "branch", "-d", assessment.branch], cwd=main)
|
|
140
120
|
report["removed"] = True
|