@ikon85/agent-workflow-kit 0.36.4 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
- package/.agents/skills/setup-workflow/SKILL.md +16 -2
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.agents/skills/wrapup/SKILL.md +20 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
- package/.claude/skills/setup-workflow/SKILL.md +16 -2
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +11 -10
- package/README.md +59 -1
- package/agent-workflow-kit.package.json +26 -26
- package/docs/agents/workflow-capabilities.json +1 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +35 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +18 -4
- package/scripts/worktree-lifecycle/cleanup.py +173 -6
- package/scripts/worktree-lifecycle/core.py +331 -18
- package/scripts/worktree-lifecycle/profile.py +2 -0
- package/scripts/wrapup-land.py +336 -15
- package/src/cli.mjs +60 -27
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +11 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/updateReconcile.mjs +6 -0
- package/src/lib/verifyUpdateCandidate.mjs +6 -3
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
- package/src/lib/verifyUpdateCandidateTransaction.mjs +23 -1
|
@@ -17,6 +17,8 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
|
|
|
17
17
|
- `setupEntry` and ordered `setupSteps`: the portable setup command and project
|
|
18
18
|
setup sequence.
|
|
19
19
|
- `riskyCommandPatterns`: commands that must target the active linked worktree.
|
|
20
|
+
- `scratchPatterns`: consumer-owned glob patterns for untracked disposable
|
|
21
|
+
planning artefacts. No filename is assumed by Core.
|
|
20
22
|
|
|
21
23
|
Unknown or malformed events fail open without changing repository state.
|
|
22
24
|
Security-sensitive, profile-matched edits and commands fail closed only when
|
|
@@ -35,10 +37,22 @@ the core proves the target is unsafe.
|
|
|
35
37
|
|
|
36
38
|
## Cleanup
|
|
37
39
|
|
|
38
|
-
`cleanup.py` previews by default. Removal refuses protected, dirty,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
`cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
|
|
41
|
+
non-scratch-dirty, open-PR, or unmerged worktrees. Profile-declared untracked
|
|
42
|
+
scratch is named in the report but does not block removal. The assessment reads
|
|
43
|
+
`ANNAHMEN.md` before removal and returns its contents for propagation.
|
|
44
|
+
`wrapup-land.py` invokes this same assessment after a merge and before killing
|
|
45
|
+
processes or removing the worktree.
|
|
46
|
+
|
|
47
|
+
Explicit removal re-collects facts immediately before mutation, requires the
|
|
48
|
+
same removable inventory, deletes only the exact contained regular scratch
|
|
49
|
+
files from that inventory, and uses ordinary `git worktree remove`. It never
|
|
50
|
+
bypasses Git's final concurrent-change check with force removal.
|
|
51
|
+
|
|
52
|
+
`cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
|
|
53
|
+
every linked worktree and local branch, reports issue/PR/merge/age/removal
|
|
54
|
+
facts, and counts merged remote branches separately. It never removes a
|
|
55
|
+
worktree or branch.
|
|
42
56
|
|
|
43
57
|
Claude hook wiring and any Codex adaptation consume this same profile and core.
|
|
44
58
|
An adapter may change only the surface event envelope; it must preserve the
|
|
@@ -4,49 +4,216 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import asdict, replace
|
|
7
9
|
import json
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
8
12
|
import sys
|
|
9
|
-
from pathlib import Path
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
10
14
|
|
|
11
|
-
from core import
|
|
15
|
+
from core import (
|
|
16
|
+
LifecycleError,
|
|
17
|
+
classify_cleanup,
|
|
18
|
+
collect_cleanup_facts,
|
|
19
|
+
collect_sweep,
|
|
20
|
+
load_profile,
|
|
21
|
+
main_worktree,
|
|
22
|
+
run,
|
|
23
|
+
)
|
|
12
24
|
|
|
13
25
|
|
|
14
26
|
def parse_args() -> argparse.Namespace:
|
|
15
27
|
parser = argparse.ArgumentParser()
|
|
16
28
|
parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
|
|
17
29
|
parser.add_argument("--remove", action="store_true")
|
|
18
|
-
parser.add_argument("
|
|
30
|
+
parser.add_argument("--gh-command", default="gh")
|
|
31
|
+
parser.add_argument("worktree", nargs="?")
|
|
19
32
|
return parser.parse_args()
|
|
20
33
|
|
|
21
34
|
|
|
35
|
+
def parse_sweep_args(argv: list[str]) -> argparse.Namespace:
|
|
36
|
+
parser = argparse.ArgumentParser(prog="cleanup.py sweep")
|
|
37
|
+
parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
|
|
38
|
+
parser.add_argument("--gh-command", default="gh")
|
|
39
|
+
return parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pr_state(gh_command: str, main: Path, branch: str) -> str:
|
|
43
|
+
remote = run(
|
|
44
|
+
["git", "remote", "get-url", "origin"],
|
|
45
|
+
cwd=main,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
if remote.returncode != 0:
|
|
49
|
+
return "none"
|
|
50
|
+
result = run(
|
|
51
|
+
[
|
|
52
|
+
gh_command, "pr", "list", "--state", "all", "--head", branch,
|
|
53
|
+
"--json", "number,state,mergedAt",
|
|
54
|
+
],
|
|
55
|
+
cwd=main,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
detail = (result.stderr or result.stdout).strip()
|
|
60
|
+
raise LifecycleError(f"cannot determine PR state for {branch}: {detail}")
|
|
61
|
+
try:
|
|
62
|
+
prs = json.loads(result.stdout)
|
|
63
|
+
except json.JSONDecodeError as error:
|
|
64
|
+
raise LifecycleError(f"cannot determine PR state for {branch}: invalid gh output") from error
|
|
65
|
+
if any(str(pr.get("state", "")).upper() == "OPEN" for pr in prs):
|
|
66
|
+
return "open"
|
|
67
|
+
if any(pr.get("mergedAt") or str(pr.get("state", "")).upper() == "MERGED" for pr in prs):
|
|
68
|
+
return "merged"
|
|
69
|
+
return "none"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def collect_assessment(profile, main: Path, worktree: Path, gh_command: str):
|
|
73
|
+
facts = collect_cleanup_facts(
|
|
74
|
+
main,
|
|
75
|
+
worktree,
|
|
76
|
+
)
|
|
77
|
+
state = pr_state(gh_command, main, facts.branch)
|
|
78
|
+
return classify_cleanup(profile, replace(facts, pr_state=state))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@contextmanager
|
|
82
|
+
def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
|
|
83
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
84
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
85
|
+
descriptor = None
|
|
86
|
+
try:
|
|
87
|
+
descriptor = os.open(root, directory_flags | no_follow)
|
|
88
|
+
metadata = os.fstat(descriptor)
|
|
89
|
+
if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
|
|
90
|
+
raise LifecycleError("worktree root changed before removal")
|
|
91
|
+
yield descriptor
|
|
92
|
+
except OSError as error:
|
|
93
|
+
raise LifecycleError("worktree root changed before removal") from error
|
|
94
|
+
finally:
|
|
95
|
+
if descriptor is not None:
|
|
96
|
+
os.close(descriptor)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def remove_contained_regular(root_descriptor: int, relative: str) -> None:
|
|
100
|
+
path = PurePosixPath(relative)
|
|
101
|
+
if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
|
|
102
|
+
raise LifecycleError(f"unsafe scratch path: {relative}")
|
|
103
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
104
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
105
|
+
descriptors = []
|
|
106
|
+
try:
|
|
107
|
+
current = root_descriptor
|
|
108
|
+
for component in path.parts[:-1]:
|
|
109
|
+
current = os.open(
|
|
110
|
+
component,
|
|
111
|
+
directory_flags | no_follow,
|
|
112
|
+
dir_fd=current,
|
|
113
|
+
)
|
|
114
|
+
descriptors.append(current)
|
|
115
|
+
metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
|
|
116
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
117
|
+
raise LifecycleError(f"scratch path is not a regular file: {relative}")
|
|
118
|
+
os.unlink(path.name, dir_fd=current)
|
|
119
|
+
except OSError as error:
|
|
120
|
+
raise LifecycleError(f"scratch path changed before removal: {relative}") from error
|
|
121
|
+
finally:
|
|
122
|
+
for descriptor in reversed(descriptors):
|
|
123
|
+
os.close(descriptor)
|
|
124
|
+
|
|
125
|
+
|
|
22
126
|
def execute(args: argparse.Namespace) -> dict:
|
|
23
127
|
main = main_worktree(Path.cwd())
|
|
24
128
|
profile_path = Path(args.profile)
|
|
25
129
|
if not profile_path.is_absolute():
|
|
26
130
|
profile_path = main / profile_path
|
|
27
131
|
profile = load_profile(profile_path)
|
|
28
|
-
|
|
132
|
+
if not args.worktree:
|
|
133
|
+
raise LifecycleError("worktree is required")
|
|
134
|
+
worktree = Path(args.worktree)
|
|
135
|
+
assessment = collect_assessment(profile, main, worktree, args.gh_command)
|
|
29
136
|
report = {
|
|
30
137
|
"worktree": str(assessment.worktree),
|
|
31
138
|
"branch": assessment.branch,
|
|
32
139
|
"removable": assessment.removable,
|
|
33
140
|
"reasons": list(assessment.reasons),
|
|
34
141
|
"assumptions": assessment.assumptions,
|
|
142
|
+
"scratchFiles": list(assessment.scratch_files),
|
|
35
143
|
"removed": False,
|
|
36
144
|
}
|
|
37
145
|
if not args.remove:
|
|
38
146
|
return report
|
|
39
147
|
if not assessment.removable:
|
|
40
148
|
raise LifecycleError("; ".join(assessment.reasons))
|
|
41
|
-
|
|
149
|
+
latest = collect_assessment(profile, main, worktree, args.gh_command)
|
|
150
|
+
if not latest.removable:
|
|
151
|
+
raise LifecycleError(f"cleanup changed before removal: {'; '.join(latest.reasons)}")
|
|
152
|
+
if (
|
|
153
|
+
latest.branch != assessment.branch
|
|
154
|
+
or latest.scratch_files != assessment.scratch_files
|
|
155
|
+
or latest.assumptions != assessment.assumptions
|
|
156
|
+
or latest.root_device != assessment.root_device
|
|
157
|
+
or latest.root_inode != assessment.root_inode
|
|
158
|
+
):
|
|
159
|
+
raise LifecycleError("cleanup changed before removal: inventory no longer matches preview")
|
|
160
|
+
with verified_worktree_root(
|
|
161
|
+
latest.worktree,
|
|
162
|
+
latest.root_device,
|
|
163
|
+
latest.root_inode,
|
|
164
|
+
) as root_descriptor:
|
|
165
|
+
for scratch in latest.scratch_files:
|
|
166
|
+
remove_contained_regular(root_descriptor, scratch)
|
|
167
|
+
run(["git", "worktree", "remove", str(latest.worktree)], cwd=main)
|
|
42
168
|
run(["git", "branch", "-d", assessment.branch], cwd=main)
|
|
43
169
|
report["removed"] = True
|
|
44
170
|
return report
|
|
45
171
|
|
|
46
172
|
|
|
173
|
+
def sweep(args: argparse.Namespace) -> dict:
|
|
174
|
+
main = main_worktree(Path.cwd())
|
|
175
|
+
profile_path = Path(args.profile)
|
|
176
|
+
if not profile_path.is_absolute():
|
|
177
|
+
profile_path = main / profile_path
|
|
178
|
+
profile = load_profile(profile_path)
|
|
179
|
+
report = collect_sweep(
|
|
180
|
+
profile,
|
|
181
|
+
main,
|
|
182
|
+
lambda branch: pr_state(args.gh_command, main, branch),
|
|
183
|
+
)
|
|
184
|
+
payload = asdict(report)
|
|
185
|
+
return {
|
|
186
|
+
"mainBranch": payload["main_branch"],
|
|
187
|
+
"worktreeCount": payload["worktree_count"],
|
|
188
|
+
"localBranchCount": payload["local_branch_count"],
|
|
189
|
+
"mergedRemoteBranchCount": payload["merged_remote_branch_count"],
|
|
190
|
+
"rows": [
|
|
191
|
+
{
|
|
192
|
+
"kind": row["kind"],
|
|
193
|
+
"path": row["path"],
|
|
194
|
+
"branch": row["branch"],
|
|
195
|
+
"issue": row["issue"],
|
|
196
|
+
"prState": row["pr_state"],
|
|
197
|
+
"mergedIntoMain": row["merged_into_main"],
|
|
198
|
+
"lastCommitAgeSeconds": row["last_commit_age_seconds"],
|
|
199
|
+
"removable": row["removable"],
|
|
200
|
+
"reasons": list(row["reasons"]),
|
|
201
|
+
"verdictReason": row["verdict_reason"],
|
|
202
|
+
"scratchFiles": list(row["scratch_files"]),
|
|
203
|
+
"assumptions": row["assumptions"],
|
|
204
|
+
}
|
|
205
|
+
for row in payload["rows"]
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
47
210
|
def main() -> int:
|
|
48
211
|
try:
|
|
49
|
-
|
|
212
|
+
if len(sys.argv) > 1 and sys.argv[1] == "sweep":
|
|
213
|
+
result = sweep(parse_sweep_args(sys.argv[2:]))
|
|
214
|
+
else:
|
|
215
|
+
result = execute(parse_args())
|
|
216
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
50
217
|
return 0
|
|
51
218
|
except LifecycleError as error:
|
|
52
219
|
print(f"STOP: {error}", file=sys.stderr)
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
from fnmatch import fnmatchcase
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from
|
|
9
|
+
from time import time
|
|
10
|
+
from typing import Any, Callable
|
|
9
11
|
|
|
10
12
|
from profile import (
|
|
11
13
|
LifecycleError,
|
|
@@ -44,12 +46,30 @@ class CleanupAssessment:
|
|
|
44
46
|
branch: str
|
|
45
47
|
assumptions: str
|
|
46
48
|
reasons: tuple[str, ...]
|
|
49
|
+
root_device: int
|
|
50
|
+
root_inode: int
|
|
51
|
+
scratch_files: tuple[str, ...] = ()
|
|
47
52
|
|
|
48
53
|
@property
|
|
49
54
|
def removable(self) -> bool:
|
|
50
55
|
return not self.reasons
|
|
51
56
|
|
|
52
57
|
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class CleanupFacts:
|
|
60
|
+
worktree: Path
|
|
61
|
+
branch: str
|
|
62
|
+
registered: bool
|
|
63
|
+
is_main: bool
|
|
64
|
+
tracked_files: tuple[str, ...]
|
|
65
|
+
untracked_files: tuple[str, ...]
|
|
66
|
+
merged: bool
|
|
67
|
+
pr_state: str
|
|
68
|
+
assumptions: str
|
|
69
|
+
root_device: int
|
|
70
|
+
root_inode: int
|
|
71
|
+
|
|
72
|
+
|
|
53
73
|
def collect_facts(cwd: Path) -> RepoFacts:
|
|
54
74
|
root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
|
|
55
75
|
main = main_worktree(root)
|
|
@@ -121,28 +141,60 @@ def cleanup_assessment(
|
|
|
121
141
|
main: Path,
|
|
122
142
|
target: Path,
|
|
123
143
|
merge_target: str | None = None,
|
|
144
|
+
pr_state: str = "none",
|
|
124
145
|
) -> CleanupAssessment:
|
|
146
|
+
return classify_cleanup(
|
|
147
|
+
profile,
|
|
148
|
+
collect_cleanup_facts(
|
|
149
|
+
main,
|
|
150
|
+
target,
|
|
151
|
+
merge_target=merge_target,
|
|
152
|
+
pr_state=pr_state,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def collect_cleanup_facts(
|
|
158
|
+
main: Path,
|
|
159
|
+
target: Path,
|
|
160
|
+
*,
|
|
161
|
+
merge_target: str | None = None,
|
|
162
|
+
pr_state: str = "none",
|
|
163
|
+
) -> CleanupFacts:
|
|
125
164
|
worktree = target.resolve()
|
|
126
|
-
|
|
165
|
+
root_metadata = worktree.stat()
|
|
127
166
|
branch = run(
|
|
128
167
|
["git", "-C", str(worktree), "branch", "--show-current"],
|
|
129
168
|
cwd=main,
|
|
130
169
|
check=False,
|
|
131
170
|
).stdout.strip()
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
171
|
+
tracked = set(run(
|
|
172
|
+
["git", "-C", str(worktree), "diff", "--name-only"],
|
|
173
|
+
cwd=main,
|
|
174
|
+
check=False,
|
|
175
|
+
).stdout.splitlines())
|
|
176
|
+
tracked.update(run(
|
|
177
|
+
["git", "-C", str(worktree), "diff", "--cached", "--name-only"],
|
|
178
|
+
cwd=main,
|
|
179
|
+
check=False,
|
|
180
|
+
).stdout.splitlines())
|
|
181
|
+
untracked = set(run(
|
|
182
|
+
["git", "-C", str(worktree), "ls-files", "--others", "--exclude-standard"],
|
|
140
183
|
cwd=main,
|
|
141
184
|
check=False,
|
|
142
|
-
).stdout
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
185
|
+
).stdout.splitlines())
|
|
186
|
+
untracked.update(run(
|
|
187
|
+
[
|
|
188
|
+
"git", "-C", str(worktree), "ls-files", "--others", "--ignored",
|
|
189
|
+
"--exclude-standard",
|
|
190
|
+
],
|
|
191
|
+
cwd=main,
|
|
192
|
+
check=False,
|
|
193
|
+
).stdout.splitlines())
|
|
194
|
+
# ANNAHMEN.md is governed separately: its bytes are returned before removal.
|
|
195
|
+
untracked.discard("ANNAHMEN.md")
|
|
196
|
+
merged = False
|
|
197
|
+
if branch:
|
|
146
198
|
main_branch = merge_target or run(
|
|
147
199
|
["git", "-C", str(main), "branch", "--show-current"],
|
|
148
200
|
cwd=main,
|
|
@@ -152,12 +204,273 @@ def cleanup_assessment(
|
|
|
152
204
|
["git", "merge-base", "--is-ancestor", branch, main_branch],
|
|
153
205
|
cwd=main,
|
|
154
206
|
check=False,
|
|
155
|
-
)
|
|
156
|
-
if merged.returncode != 0:
|
|
157
|
-
reasons.append(f"unmerged branch: {branch}")
|
|
207
|
+
).returncode == 0
|
|
158
208
|
assumptions_path = worktree / "ANNAHMEN.md"
|
|
159
209
|
assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
|
|
160
|
-
return
|
|
210
|
+
return CleanupFacts(
|
|
211
|
+
worktree=worktree,
|
|
212
|
+
branch=branch,
|
|
213
|
+
registered=worktree in registered_worktrees(main),
|
|
214
|
+
is_main=worktree == main.resolve(),
|
|
215
|
+
tracked_files=tuple(sorted(tracked)),
|
|
216
|
+
untracked_files=tuple(sorted(untracked)),
|
|
217
|
+
merged=merged,
|
|
218
|
+
pr_state=pr_state,
|
|
219
|
+
assumptions=assumptions,
|
|
220
|
+
root_device=root_metadata.st_dev,
|
|
221
|
+
root_inode=root_metadata.st_ino,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def classify_cleanup(
|
|
226
|
+
profile: WorktreeProfile,
|
|
227
|
+
facts: CleanupFacts,
|
|
228
|
+
) -> CleanupAssessment:
|
|
229
|
+
reasons = []
|
|
230
|
+
if not facts.registered:
|
|
231
|
+
reasons.append("not a registered worktree")
|
|
232
|
+
if not facts.branch:
|
|
233
|
+
reasons.append("detached or unreadable branch")
|
|
234
|
+
if facts.branch in profile.protected_branches or facts.is_main:
|
|
235
|
+
reasons.append(f"protected worktree branch: {facts.branch or '<unknown>'}")
|
|
236
|
+
scratch = sorted(
|
|
237
|
+
path for path in facts.untracked_files
|
|
238
|
+
if any(fnmatchcase(path, pattern) for pattern in profile.scratch_patterns)
|
|
239
|
+
)
|
|
240
|
+
non_scratch = sorted(set(facts.untracked_files).difference(scratch))
|
|
241
|
+
if facts.tracked_files:
|
|
242
|
+
reasons.append(
|
|
243
|
+
f"dirty worktree: tracked modifications: {', '.join(facts.tracked_files)}"
|
|
244
|
+
)
|
|
245
|
+
if non_scratch:
|
|
246
|
+
reasons.append(f"dirty worktree: untracked non-scratch: {', '.join(non_scratch)}")
|
|
247
|
+
if facts.pr_state == "open":
|
|
248
|
+
reasons.append("open PR")
|
|
249
|
+
if (
|
|
250
|
+
facts.branch
|
|
251
|
+
and facts.branch not in profile.protected_branches
|
|
252
|
+
and not facts.merged
|
|
253
|
+
):
|
|
254
|
+
reasons.append(f"unmerged branch: {facts.branch}")
|
|
255
|
+
return CleanupAssessment(
|
|
256
|
+
facts.worktree,
|
|
257
|
+
facts.branch,
|
|
258
|
+
facts.assumptions,
|
|
259
|
+
tuple(reasons),
|
|
260
|
+
facts.root_device,
|
|
261
|
+
facts.root_inode,
|
|
262
|
+
tuple(scratch),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class SweepRow:
|
|
268
|
+
kind: str
|
|
269
|
+
path: str | None
|
|
270
|
+
branch: str
|
|
271
|
+
issue: str | None
|
|
272
|
+
pr_state: str
|
|
273
|
+
merged_into_main: bool
|
|
274
|
+
last_commit_age_seconds: int
|
|
275
|
+
removable: bool
|
|
276
|
+
reasons: tuple[str, ...]
|
|
277
|
+
verdict_reason: str
|
|
278
|
+
scratch_files: tuple[str, ...] = ()
|
|
279
|
+
assumptions: str = ""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class SweepReport:
|
|
284
|
+
main_branch: str
|
|
285
|
+
worktree_count: int
|
|
286
|
+
local_branch_count: int
|
|
287
|
+
merged_remote_branch_count: int
|
|
288
|
+
rows: tuple[SweepRow, ...]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class SweepFactRow:
|
|
293
|
+
path: Path | None
|
|
294
|
+
branch: str
|
|
295
|
+
pr_state: str
|
|
296
|
+
merged_into_main: bool
|
|
297
|
+
last_commit_age_seconds: int
|
|
298
|
+
cleanup: CleanupFacts | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class SweepFacts:
|
|
303
|
+
main: Path
|
|
304
|
+
main_branch: str
|
|
305
|
+
worktree_count: int
|
|
306
|
+
local_branch_count: int
|
|
307
|
+
merged_remote_branch_count: int
|
|
308
|
+
rows: tuple[SweepFactRow, ...]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _worktree_branches(main: Path) -> tuple[dict[str, Path], tuple[Path, ...]]:
|
|
312
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
|
|
313
|
+
linked: dict[str, Path] = {}
|
|
314
|
+
detached = []
|
|
315
|
+
path: Path | None = None
|
|
316
|
+
branch = ""
|
|
317
|
+
for line in [*output.splitlines(), ""]:
|
|
318
|
+
if line.startswith("worktree "):
|
|
319
|
+
path = Path(line.split(" ", 1)[1]).resolve()
|
|
320
|
+
branch = ""
|
|
321
|
+
elif line.startswith("branch refs/heads/"):
|
|
322
|
+
branch = line.removeprefix("branch refs/heads/")
|
|
323
|
+
elif not line and path is not None:
|
|
324
|
+
if branch:
|
|
325
|
+
linked[branch] = path
|
|
326
|
+
else:
|
|
327
|
+
detached.append(path)
|
|
328
|
+
path = None
|
|
329
|
+
return linked, tuple(detached)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def collect_sweep_facts(
|
|
333
|
+
profile: WorktreeProfile,
|
|
334
|
+
main: Path,
|
|
335
|
+
pr_lookup: Callable[[str], str],
|
|
336
|
+
*,
|
|
337
|
+
now: int | None = None,
|
|
338
|
+
) -> SweepFacts:
|
|
339
|
+
"""Gather the complete read-only inventory without making removal decisions."""
|
|
340
|
+
main = main.resolve()
|
|
341
|
+
main_branch = run(
|
|
342
|
+
["git", "-C", str(main), "branch", "--show-current"], cwd=main
|
|
343
|
+
).stdout.strip()
|
|
344
|
+
linked, detached = _worktree_branches(main)
|
|
345
|
+
refs = run(
|
|
346
|
+
[
|
|
347
|
+
"git", "for-each-ref",
|
|
348
|
+
"--format=%(refname:short)\t%(committerdate:unix)",
|
|
349
|
+
"refs/heads/",
|
|
350
|
+
],
|
|
351
|
+
cwd=main,
|
|
352
|
+
).stdout.splitlines()
|
|
353
|
+
timestamp = int(time()) if now is None else now
|
|
354
|
+
rows: list[SweepFactRow] = []
|
|
355
|
+
for line in refs:
|
|
356
|
+
branch, commit_time = line.rsplit("\t", 1)
|
|
357
|
+
path = linked.get(branch)
|
|
358
|
+
pr_state = pr_lookup(branch)
|
|
359
|
+
merged = run(
|
|
360
|
+
["git", "merge-base", "--is-ancestor", branch, main_branch],
|
|
361
|
+
cwd=main,
|
|
362
|
+
check=False,
|
|
363
|
+
).returncode == 0
|
|
364
|
+
rows.append(SweepFactRow(
|
|
365
|
+
path=path,
|
|
366
|
+
branch=branch,
|
|
367
|
+
pr_state=pr_state,
|
|
368
|
+
merged_into_main=merged,
|
|
369
|
+
last_commit_age_seconds=max(0, timestamp - int(commit_time)),
|
|
370
|
+
cleanup=collect_cleanup_facts(
|
|
371
|
+
main,
|
|
372
|
+
path,
|
|
373
|
+
merge_target=main_branch,
|
|
374
|
+
pr_state=pr_state,
|
|
375
|
+
) if path is not None else None,
|
|
376
|
+
))
|
|
377
|
+
for path in detached:
|
|
378
|
+
commit_time = run(
|
|
379
|
+
["git", "-C", str(path), "show", "-s", "--format=%ct", "HEAD"],
|
|
380
|
+
cwd=main,
|
|
381
|
+
).stdout.strip()
|
|
382
|
+
rows.append(SweepFactRow(
|
|
383
|
+
path=path,
|
|
384
|
+
branch="",
|
|
385
|
+
pr_state="none",
|
|
386
|
+
merged_into_main=False,
|
|
387
|
+
last_commit_age_seconds=max(0, timestamp - int(commit_time)),
|
|
388
|
+
))
|
|
389
|
+
remote_merged = run(
|
|
390
|
+
[
|
|
391
|
+
"git", "for-each-ref",
|
|
392
|
+
f"--merged={main_branch}",
|
|
393
|
+
"--format=%(refname:short)",
|
|
394
|
+
"refs/remotes/",
|
|
395
|
+
],
|
|
396
|
+
cwd=main,
|
|
397
|
+
check=False,
|
|
398
|
+
).stdout.splitlines()
|
|
399
|
+
return SweepFacts(
|
|
400
|
+
main=main,
|
|
401
|
+
main_branch=main_branch,
|
|
402
|
+
worktree_count=len(linked) + len(detached),
|
|
403
|
+
local_branch_count=len(refs),
|
|
404
|
+
merged_remote_branch_count=len([
|
|
405
|
+
branch for branch in remote_merged if branch and not branch.endswith("/HEAD")
|
|
406
|
+
]),
|
|
407
|
+
rows=tuple(rows),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def classify_sweep(profile: WorktreeProfile, facts: SweepFacts) -> SweepReport:
|
|
412
|
+
"""Apply profile policy to already-collected inventory facts."""
|
|
413
|
+
rows = []
|
|
414
|
+
for fact in facts.rows:
|
|
415
|
+
if fact.cleanup is not None:
|
|
416
|
+
assessment = classify_cleanup(profile, fact.cleanup)
|
|
417
|
+
reasons = assessment.reasons
|
|
418
|
+
scratch = assessment.scratch_files
|
|
419
|
+
assumptions = assessment.assumptions
|
|
420
|
+
elif fact.path is not None:
|
|
421
|
+
reasons = ("detached or unreadable branch",)
|
|
422
|
+
scratch = ()
|
|
423
|
+
assumptions = ""
|
|
424
|
+
else:
|
|
425
|
+
reasons_list = []
|
|
426
|
+
if fact.branch in profile.protected_branches:
|
|
427
|
+
reasons_list.append(f"protected branch: {fact.branch}")
|
|
428
|
+
if fact.pr_state == "open":
|
|
429
|
+
reasons_list.append("open PR")
|
|
430
|
+
if not fact.merged_into_main:
|
|
431
|
+
reasons_list.append(f"unmerged branch: {fact.branch}")
|
|
432
|
+
reasons = tuple(reasons_list)
|
|
433
|
+
scratch = ()
|
|
434
|
+
assumptions = ""
|
|
435
|
+
rows.append(SweepRow(
|
|
436
|
+
kind="worktree" if fact.path is not None else "branch",
|
|
437
|
+
path=str(fact.path) if fact.path is not None else None,
|
|
438
|
+
branch=fact.branch,
|
|
439
|
+
issue=profile.issue_from_branch(fact.branch),
|
|
440
|
+
pr_state=fact.pr_state,
|
|
441
|
+
merged_into_main=fact.merged_into_main,
|
|
442
|
+
last_commit_age_seconds=fact.last_commit_age_seconds,
|
|
443
|
+
removable=not reasons,
|
|
444
|
+
reasons=reasons,
|
|
445
|
+
verdict_reason=(
|
|
446
|
+
"; ".join(reasons)
|
|
447
|
+
if reasons
|
|
448
|
+
else (
|
|
449
|
+
f"merged into {facts.main_branch}; scratch-only: {', '.join(scratch)}"
|
|
450
|
+
if scratch
|
|
451
|
+
else f"merged into {facts.main_branch}; no blocking work"
|
|
452
|
+
)
|
|
453
|
+
),
|
|
454
|
+
scratch_files=scratch,
|
|
455
|
+
assumptions=assumptions,
|
|
456
|
+
))
|
|
457
|
+
return SweepReport(
|
|
458
|
+
main_branch=facts.main_branch,
|
|
459
|
+
worktree_count=facts.worktree_count,
|
|
460
|
+
local_branch_count=facts.local_branch_count,
|
|
461
|
+
merged_remote_branch_count=facts.merged_remote_branch_count,
|
|
462
|
+
rows=tuple(rows),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def collect_sweep(
|
|
467
|
+
profile: WorktreeProfile,
|
|
468
|
+
main: Path,
|
|
469
|
+
pr_lookup: Callable[[str], str],
|
|
470
|
+
*,
|
|
471
|
+
now: int | None = None,
|
|
472
|
+
) -> SweepReport:
|
|
473
|
+
return classify_sweep(profile, collect_sweep_facts(profile, main, pr_lookup, now=now))
|
|
161
474
|
|
|
162
475
|
|
|
163
476
|
def edit_decision(
|
|
@@ -25,6 +25,7 @@ class WorktreeProfile:
|
|
|
25
25
|
branch_regex: str
|
|
26
26
|
setup_entry: str
|
|
27
27
|
risky_command_patterns: tuple[str, ...]
|
|
28
|
+
scratch_patterns: tuple[str, ...]
|
|
28
29
|
|
|
29
30
|
def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
|
|
30
31
|
return _render(self.branch_template, issue, slug, branch_type)
|
|
@@ -74,6 +75,7 @@ def load_profile(path: Path) -> WorktreeProfile:
|
|
|
74
75
|
r"\b(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|typecheck|build)\b",
|
|
75
76
|
r"\bgit\s+(?:commit|push)\b",
|
|
76
77
|
)),
|
|
78
|
+
scratch_patterns=tuple(raw.get("scratchPatterns") or ()),
|
|
77
79
|
)
|
|
78
80
|
|
|
79
81
|
|