@caddis/cli 0.4.66 → 0.4.67
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_created.py +191 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +11 -3
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +4 -0
- package/bundles/antigravity-plugin/scripts/caddis_own_cleanup.py +424 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +26 -3
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +28 -3
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +7 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +7 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""What a session CREATED and must clean up: worktrees and scratch folders, recorded per session.
|
|
2
|
+
|
|
3
|
+
Why a file of its own. The Stop hook rewrites `.caddis/session-state/<id>.md` every turn, so a record kept there is
|
|
4
|
+
lost. A session also often makes worktrees in OTHER repos. So the record lives outside every repo:
|
|
5
|
+
|
|
6
|
+
~/.caddis/sessions/<session-id>.created.jsonl (append-only; one JSON object per line)
|
|
7
|
+
|
|
8
|
+
(`~` is `$CADDIS_HOME` when set, else the user's home folder; the `home` argument beats both. The folder also holds
|
|
9
|
+
other code's `<session-id>.json` files; this module never touches those.)
|
|
10
|
+
|
|
11
|
+
A line is one fact about one path:
|
|
12
|
+
|
|
13
|
+
{"path", "kind": "worktree"|"scratch", "created": <ISO UTC>, "purpose", "pr": str|null, "repo": str|null,
|
|
14
|
+
"branch": str|null, "status": "created"|"removed", "by": "helper"|"lanes"|"roadmap-run"|"docs-pr"|
|
|
15
|
+
"transcript"|"cli"}
|
|
16
|
+
|
|
17
|
+
`read` FOLDS the lines by normalised path (case-insensitive on Windows): `created` keeps the first value and every
|
|
18
|
+
other field takes the latest non-null value. So "record again with the PR number" and "mark removed" are just new
|
|
19
|
+
lines. One exception, which is a safety rule: a `created` line for a path whose last status was `removed` starts a
|
|
20
|
+
NEW entry. A path that is used again (a roadmap worktree has a fixed name) must not inherit the old PR.
|
|
21
|
+
|
|
22
|
+
This module only RECORDS. It deletes nothing; `cleanup` hands off to `caddis_own_cleanup.py`, which does.
|
|
23
|
+
|
|
24
|
+
session_id(explicit=None) -> a safe session id, or None
|
|
25
|
+
record(path, kind, purpose, *, pr, repo, branch, status, by, session_id, home) -> bool (never raises)
|
|
26
|
+
read(session_id, home=None) -> list[dict] (folded; oldest first; bad lines skipped)
|
|
27
|
+
|
|
28
|
+
CLI (exit 0 on success; `record` with no session id warns on stderr and still exits 0, so a caller never breaks;
|
|
29
|
+
exit 2 on bad arguments):
|
|
30
|
+
python caddis_created.py record --path P --kind worktree --purpose "why" [--pr N] [--repo R] [--branch B]
|
|
31
|
+
[--status removed] [--by cli] [--session-id S]
|
|
32
|
+
python caddis_created.py list [--session-id S] [--json]
|
|
33
|
+
python caddis_created.py cleanup [--session-id S] [--apply] [--json] (dry run by default; always exit 0)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
SESSION_ENV_VARS = ("CADDIS_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID") # as caddis_adhoc.session_key
|
|
47
|
+
KINDS = ("worktree", "scratch")
|
|
48
|
+
STATUSES = ("created", "removed")
|
|
49
|
+
BYS = ("helper", "lanes", "roadmap-run", "docs-pr", "transcript", "cli")
|
|
50
|
+
_SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,100}") # no separator, drive colon or dots: a plain file-name stem
|
|
51
|
+
_FIELDS = ("purpose", "pr", "repo", "branch", "by")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sid(explicit: str | None = None) -> str | None:
|
|
55
|
+
"""`explicit` (even "", which gives None), else the first non-blank session env var. None when there is none or not a safe file
|
|
56
|
+
name (a bad explicit id does NOT fall back to the environment: that would record under the wrong session)."""
|
|
57
|
+
candidates = [explicit] if explicit is not None else [os.environ.get(name) for name in SESSION_ENV_VARS]
|
|
58
|
+
for raw in candidates:
|
|
59
|
+
value = (raw or "").strip()
|
|
60
|
+
if value:
|
|
61
|
+
return value if _SAFE_ID.fullmatch(value) else None
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def session_id(explicit: str | None = None) -> str | None:
|
|
66
|
+
"""The session id to record under: see `_sid`."""
|
|
67
|
+
return _sid(explicit)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _file(sid: str, home: Path | str | None) -> Path:
|
|
71
|
+
root = Path(home) if home else Path(os.environ.get("CADDIS_HOME") or Path.home())
|
|
72
|
+
return root / ".caddis" / "sessions" / f"{sid}.created.jsonl"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalise(path: str | os.PathLike) -> str:
|
|
76
|
+
"""Absolute, `..` collapsed. Not resolved: resolving would follow a link, and the record names the path as made."""
|
|
77
|
+
return os.path.abspath(os.fspath(path))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _key(path: str) -> str:
|
|
81
|
+
return os.path.normcase(path) # lower-case and backslashes on Windows; unchanged elsewhere
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def record(path: str | os.PathLike, kind: str, purpose: str = "", *, pr: str | int | None = None,
|
|
85
|
+
repo: str | os.PathLike | None = None, branch: str | None = None, status: str = "created",
|
|
86
|
+
by: str = "cli", session_id: str | None = None, home: Path | str | None = None) -> bool:
|
|
87
|
+
"""Append one line. False, and nothing written, with no usable session id, an unknown kind or status, or any I/O
|
|
88
|
+
error. Never raises: callers are hooks and runners."""
|
|
89
|
+
try:
|
|
90
|
+
sid = _sid(session_id)
|
|
91
|
+
if not sid or kind not in KINDS or status not in STATUSES or not str(path).strip():
|
|
92
|
+
return False
|
|
93
|
+
line = {
|
|
94
|
+
"path": _normalise(path), "kind": kind,
|
|
95
|
+
"created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
96
|
+
"purpose": str(purpose or ""), "pr": None if pr is None or str(pr) == "" else str(pr),
|
|
97
|
+
"repo": _normalise(repo) if repo else None, "branch": branch or None, "status": status, "by": by,
|
|
98
|
+
}
|
|
99
|
+
target = _file(sid, home)
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with open(target, "a", encoding="utf-8", newline="\n") as handle:
|
|
102
|
+
handle.write(json.dumps(line, ensure_ascii=False) + "\n") # one write call: appends do not interleave
|
|
103
|
+
return True
|
|
104
|
+
except Exception: # noqa: BLE001 - a recording failure must never break the caller
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read(session_id: str, home: Path | str | None = None) -> list[dict]:
|
|
109
|
+
"""The session's entries, folded by path, oldest `created` first. [] when there is no file; bad lines are skipped."""
|
|
110
|
+
try:
|
|
111
|
+
sid = session_id if _SAFE_ID.fullmatch(session_id or "") else None # strict: no env fallback when reading
|
|
112
|
+
if not sid:
|
|
113
|
+
return []
|
|
114
|
+
text = _file(sid, home).read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
116
|
+
return []
|
|
117
|
+
folded: dict[str, dict] = {}
|
|
118
|
+
for raw in text.splitlines():
|
|
119
|
+
try:
|
|
120
|
+
line = json.loads(raw)
|
|
121
|
+
except ValueError:
|
|
122
|
+
continue
|
|
123
|
+
if not (isinstance(line, dict) and isinstance(line.get("path"), str) and line["path"]
|
|
124
|
+
and line.get("kind") in KINDS and line.get("status") in STATUSES):
|
|
125
|
+
continue
|
|
126
|
+
key = _key(line["path"])
|
|
127
|
+
entry = folded.get(key)
|
|
128
|
+
if entry is None or (entry["status"] == "removed" and line["status"] == "created"):
|
|
129
|
+
folded[key] = {"path": line["path"], "kind": line["kind"], "created": str(line.get("created") or ""),
|
|
130
|
+
"status": line["status"], **{name: line.get(name) for name in _FIELDS}}
|
|
131
|
+
continue
|
|
132
|
+
entry["status"] = line["status"]
|
|
133
|
+
entry["kind"] = line["kind"]
|
|
134
|
+
for name in _FIELDS:
|
|
135
|
+
if line.get(name) is not None and not (name == "purpose" and line[name] == ""):
|
|
136
|
+
entry[name] = line[name]
|
|
137
|
+
return sorted(folded.values(), key=lambda e: e["created"]) # stable: equal stamps keep file order
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _summary(entry: dict) -> str:
|
|
141
|
+
text = f"{entry['path']} | {entry['kind']} | {entry['status']} | {entry.get('purpose') or ''}"
|
|
142
|
+
return text + (f" | PR {entry['pr']}" if entry.get("pr") else "")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None) -> int:
|
|
146
|
+
ap = argparse.ArgumentParser(description="Record or list what this session created (worktrees, scratch folders).")
|
|
147
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
148
|
+
rec = sub.add_parser("record", help="append one line for a path")
|
|
149
|
+
rec.add_argument("--path", required=True)
|
|
150
|
+
rec.add_argument("--kind", required=True, choices=KINDS)
|
|
151
|
+
rec.add_argument("--purpose", default="")
|
|
152
|
+
rec.add_argument("--pr", default=None)
|
|
153
|
+
rec.add_argument("--repo", default=None)
|
|
154
|
+
rec.add_argument("--branch", default=None)
|
|
155
|
+
rec.add_argument("--status", default="created", choices=STATUSES)
|
|
156
|
+
rec.add_argument("--by", default="cli", choices=BYS)
|
|
157
|
+
rec.add_argument("--session-id", default=None)
|
|
158
|
+
lst = sub.add_parser("list", help="print the folded entries")
|
|
159
|
+
lst.add_argument("--session-id", default=None)
|
|
160
|
+
lst.add_argument("--json", action="store_true")
|
|
161
|
+
cln = sub.add_parser("cleanup", help="remove this session's merged, clean worktrees and scratch folders")
|
|
162
|
+
cln.add_argument("--session-id", default=None)
|
|
163
|
+
cln.add_argument("--apply", action="store_true", help="act; without it, only list (the default)")
|
|
164
|
+
cln.add_argument("--json", action="store_true")
|
|
165
|
+
try:
|
|
166
|
+
args = ap.parse_args(argv)
|
|
167
|
+
except SystemExit as exc:
|
|
168
|
+
return int(exc.code) if isinstance(exc.code, int) else 2
|
|
169
|
+
sid = session_id(args.session_id)
|
|
170
|
+
if args.cmd == "record":
|
|
171
|
+
if not record(args.path, args.kind, args.purpose, pr=args.pr, repo=args.repo, branch=args.branch,
|
|
172
|
+
status=args.status, by=args.by, session_id=args.session_id):
|
|
173
|
+
why = "no usable session id" if not sid else "the record could not be written"
|
|
174
|
+
print(f"caddis_created: not recorded ({why}): {args.path}", file=sys.stderr)
|
|
175
|
+
return 0
|
|
176
|
+
if args.cmd == "cleanup":
|
|
177
|
+
import caddis_own_cleanup # it deletes, so it lives apart from this record-only module (and imports it)
|
|
178
|
+
return caddis_own_cleanup.run(sid, args.apply, args.json)
|
|
179
|
+
if not sid:
|
|
180
|
+
print("caddis_created: no usable session id; nothing to list", file=sys.stderr)
|
|
181
|
+
entries = read(sid) if sid else []
|
|
182
|
+
if args.json:
|
|
183
|
+
print(json.dumps(entries, indent=2, ensure_ascii=False))
|
|
184
|
+
else:
|
|
185
|
+
for entry in entries:
|
|
186
|
+
print(_summary(entry))
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
sys.exit(main())
|
|
@@ -37,15 +37,18 @@ import tempfile
|
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
|
|
39
39
|
try:
|
|
40
|
+
import caddis_created
|
|
40
41
|
import caddis_exit
|
|
41
42
|
import caddis_checkout_guard
|
|
42
43
|
import caddis_roadmap as roadmap
|
|
43
44
|
import caddis_worktree_safety
|
|
44
45
|
except ModuleNotFoundError as exc:
|
|
45
|
-
if exc.name not in {"caddis_exit", "caddis_checkout_guard", "caddis_roadmap",
|
|
46
|
+
if exc.name not in {"caddis_created", "caddis_exit", "caddis_checkout_guard", "caddis_roadmap",
|
|
47
|
+
"caddis_worktree_safety"}:
|
|
46
48
|
raise
|
|
47
49
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
48
50
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
51
|
+
import caddis_created
|
|
49
52
|
import caddis_exit
|
|
50
53
|
import caddis_checkout_guard
|
|
51
54
|
import caddis_roadmap as roadmap
|
|
@@ -87,6 +90,7 @@ def start(repo: Path, out=print) -> int:
|
|
|
87
90
|
worktree = tmp_root / f"{DIR_PREFIX}{stamp}{suffix}"
|
|
88
91
|
branch = f"{BRANCH_PREFIX}{stamp}{suffix}"
|
|
89
92
|
_git(repo, "worktree", "add", "--quiet", "-b", branch, str(worktree), f"origin/{base}")
|
|
93
|
+
caddis_created.record(worktree, "worktree", "handoff docs PR", repo=repo, branch=branch, by="docs-pr")
|
|
90
94
|
out(f"WORKTREE: {worktree}")
|
|
91
95
|
return caddis_exit.CLEAN
|
|
92
96
|
|
|
@@ -199,7 +203,7 @@ def _save_patch(worktree: Path) -> Path:
|
|
|
199
203
|
return Path(name)
|
|
200
204
|
|
|
201
205
|
|
|
202
|
-
def _remove(main: Path, worktree: Path, branch: str) -> None:
|
|
206
|
+
def _remove(main: Path, worktree: Path, branch: str, pr: str | None = None) -> None:
|
|
203
207
|
"""Remove the worktree (links first, so a junction's target survives), prune, delete the branch.
|
|
204
208
|
|
|
205
209
|
Best effort, as before: the result of each step is not checked. Every caller has already saved the session's
|
|
@@ -215,6 +219,9 @@ def _remove(main: Path, worktree: Path, branch: str) -> None:
|
|
|
215
219
|
pass
|
|
216
220
|
if worktree.exists():
|
|
217
221
|
caddis_worktree_safety.remove_tree(worktree)
|
|
222
|
+
if not worktree.exists(): # only a folder that is really gone is recorded as removed
|
|
223
|
+
caddis_created.record(worktree, "worktree", "", pr=pr, repo=main, branch=branch, status="removed",
|
|
224
|
+
by="docs-pr")
|
|
218
225
|
|
|
219
226
|
|
|
220
227
|
def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
@@ -257,7 +264,8 @@ def finish(worktree: Path, title: str, body: str, out=print) -> int:
|
|
|
257
264
|
out(f"PR: {url}")
|
|
258
265
|
for path in files:
|
|
259
266
|
out(f"FILE: {path}")
|
|
260
|
-
|
|
267
|
+
pr_number = re.search(r"/pull/(\d+)\s*$", url)
|
|
268
|
+
_remove(main, worktree, branch, pr=pr_number.group(1) if pr_number else None)
|
|
261
269
|
try:
|
|
262
270
|
patch.unlink()
|
|
263
271
|
except OSError:
|
|
@@ -34,6 +34,7 @@ except ModuleNotFoundError as exc:
|
|
|
34
34
|
from caddis_heavy_slot import HeavySlotBusy, heavy_slot
|
|
35
35
|
|
|
36
36
|
import caddis_checkout_guard
|
|
37
|
+
import caddis_created
|
|
37
38
|
import caddis_routing
|
|
38
39
|
import caddis_redact
|
|
39
40
|
import caddis_spend
|
|
@@ -284,6 +285,8 @@ def create_phase_worktree(
|
|
|
284
285
|
str(path),
|
|
285
286
|
base_sha,
|
|
286
287
|
)
|
|
288
|
+
caddis_created.record(path, "worktree", f"lane {plan_stem} phase {phase:02d} attempt {attempt}", repo=repo_root,
|
|
289
|
+
branch=branch, by="lanes")
|
|
287
290
|
print(f"[caddis-lanes] worktree: {path}", file=sys.stderr)
|
|
288
291
|
return Worktree(path=path, branch=branch, base_sha=base_sha)
|
|
289
292
|
|
|
@@ -307,6 +310,7 @@ def remove_phase_worktree(
|
|
|
307
310
|
problem = f"worktree remove left {resolved} on disk"
|
|
308
311
|
if problem:
|
|
309
312
|
raise LaneConfigError(problem)
|
|
313
|
+
caddis_created.record(resolved, "worktree", "", repo=repo_root, branch=wt.branch, status="removed", by="lanes")
|
|
310
314
|
if delete_branch:
|
|
311
315
|
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
312
316
|
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Remove THIS session's own leftovers at handoff: merged, clean worktrees and scratch folders (tidy v2, Phase 2).
|
|
2
|
+
|
|
3
|
+
Input is the session's record (`caddis_created.read`): only the paths this session made. Each entry with status
|
|
4
|
+
`created` gets one decision; the first rule that matches wins. Anything in doubt is KEPT, with the reason.
|
|
5
|
+
|
|
6
|
+
Worktree (in order):
|
|
7
|
+
by roadmap-run -> KEPT (the runner reuses and removes its own) | missing -> GONE | a link, the main checkout, or
|
|
8
|
+
not a registered linked worktree -> KEPT | the process cwd is in it -> KEPT | `git status` not clean (untracked
|
|
9
|
+
counts) -> KEPT | locked -> KEPT | on the default branch -> KEPT | no merged PR (no gh, gh error, no PR, PR not
|
|
10
|
+
MERGED, PR head branch differs) -> KEPT | PR head commit unknown here, or commits after it -> KEPT | a file the branch changed differs
|
|
11
|
+
from `origin/<default>` (fetched first; this is the squash-merge proof that the work landed) -> KEPT |
|
|
12
|
+
else REMOVE: `safe_remove_worktree` (links first, never --force), then the local branch (only while its tip is
|
|
13
|
+
HEAD or the PR head and no other worktree has it), then the remote branch (only while its tip is the PR head or
|
|
14
|
+
the local tip, as one compare-and-delete push).
|
|
15
|
+
Scratch: missing -> GONE | a link, a root or home folder, or holds the cwd -> KEPT | inside a git checkout and
|
|
16
|
+
holds tracked files -> KEPT | a full scan (links never followed) that hits an unreadable folder -> KEPT | holds a git repo that is dirty, has commits on no remote, or is
|
|
17
|
+
a linked worktree -> KEPT | else REMOVE via `remove_tree` (links first).
|
|
18
|
+
|
|
19
|
+
Dry run is the default; `apply=True` acts and records `status=removed` (GONE too). Nothing raises to the caller.
|
|
20
|
+
|
|
21
|
+
cleanup(session_id, *, apply, home, pr_lookup, cwd) -> list[{path, kind, action, reason}]
|
|
22
|
+
action: REMOVE (dry run) | REMOVED | KEPT | GONE | FAILED
|
|
23
|
+
summary(results, apply) -> "[OWN-CLEANUP] removed <n>, kept <n>, gone <n>, failed <n>"
|
|
24
|
+
run(session_id, apply, as_json) -> 0 (the `caddis_created.py cleanup` CLI)
|
|
25
|
+
|
|
26
|
+
`pr_lookup(main_repo, branch, pr) -> list[{number, state, headRefOid, headRefName}]` (newest first) is the gh
|
|
27
|
+
seam; it raises PrLookupError. The default calls plain `gh` with the inherited environment (never `gh auth switch`).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import caddis_created
|
|
41
|
+
import caddis_worktree_safety as safety
|
|
42
|
+
|
|
43
|
+
GIT_TIMEOUT = 60
|
|
44
|
+
NET_TIMEOUT = 120
|
|
45
|
+
_GH_FIELDS = "number,state,headRefOid,mergedAt,headRefName"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PrLookupError(Exception):
|
|
49
|
+
"""gh is missing, failed, or the repo cannot be named."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _env() -> dict[str, str]:
|
|
53
|
+
env = dict(os.environ)
|
|
54
|
+
env["GIT_TERMINAL_PROMPT"] = "0" # a credential prompt must fail, not hang the handoff
|
|
55
|
+
return env
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
59
|
+
"""Never raises: a missing program or a timeout comes back as returncode -1 with the problem in stderr."""
|
|
60
|
+
try:
|
|
61
|
+
return subprocess.run(cmd, cwd=str(cwd) if cwd else None, capture_output=True, text=True, encoding="utf-8",
|
|
62
|
+
errors="replace", stdin=subprocess.DEVNULL, timeout=timeout, check=False, env=_env())
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return subprocess.CompletedProcess(cmd, -1, "", f"{cmd[0]} is not installed")
|
|
65
|
+
except subprocess.TimeoutExpired:
|
|
66
|
+
return subprocess.CompletedProcess(cmd, -1, "", f"{cmd[0]} timed out after {timeout} s")
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
return subprocess.CompletedProcess(cmd, -1, "", str(exc))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _git(path: Path, *args: str, timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
72
|
+
return _run(["git", "-c", "core.quotepath=off", "-C", str(path), *args], timeout=timeout)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _err(proc: subprocess.CompletedProcess) -> str:
|
|
76
|
+
text = (proc.stderr or proc.stdout or "").strip()
|
|
77
|
+
return text.splitlines()[-1] if text else f"exit {proc.returncode}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _same(a: str | Path, b: str | Path) -> bool:
|
|
81
|
+
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _inside(child: str | Path, parent: str | Path) -> bool:
|
|
85
|
+
"""`child` is `parent` or under it (both resolved, case-folded on Windows)."""
|
|
86
|
+
c = os.path.normcase(os.path.realpath(child))
|
|
87
|
+
p = os.path.normcase(os.path.realpath(parent))
|
|
88
|
+
return c == p or c.startswith(p.rstrip("\\/") + os.sep)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# -- gh ---------------------------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _slug(main_repo: Path) -> str | None:
|
|
94
|
+
"""`owner/name` from the origin URL (https, ssh or scp form), else None."""
|
|
95
|
+
proc = _git(main_repo, "remote", "get-url", "origin")
|
|
96
|
+
url = proc.stdout.strip() if proc.returncode == 0 else ""
|
|
97
|
+
if "://" not in url and "@" not in url:
|
|
98
|
+
return None # a local path origin has no GitHub name
|
|
99
|
+
match = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url)
|
|
100
|
+
return f"{match.group(1)}/{match.group(2)}" if match else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def gh_pr_lookup(main_repo: Path, branch: str | None, pr: str | None) -> list[dict]:
|
|
104
|
+
slug = _slug(main_repo)
|
|
105
|
+
if not slug:
|
|
106
|
+
raise PrLookupError("cannot read owner/name from the origin URL")
|
|
107
|
+
if pr:
|
|
108
|
+
proc = _run(["gh", "pr", "view", pr, "--repo", slug, "--json", _GH_FIELDS], timeout=NET_TIMEOUT)
|
|
109
|
+
elif branch:
|
|
110
|
+
proc = _run(["gh", "pr", "list", "--repo", slug, "--head", branch, "--state", "all", "--limit", "20",
|
|
111
|
+
"--json", _GH_FIELDS], timeout=NET_TIMEOUT)
|
|
112
|
+
else:
|
|
113
|
+
return []
|
|
114
|
+
if proc.returncode != 0:
|
|
115
|
+
raise PrLookupError(f"gh failed: {_err(proc)}")
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(proc.stdout or "null")
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
raise PrLookupError(f"gh printed bad JSON: {exc}") from exc
|
|
120
|
+
data = [data] if isinstance(data, dict) else data
|
|
121
|
+
if not isinstance(data, list):
|
|
122
|
+
raise PrLookupError("gh printed an unexpected shape")
|
|
123
|
+
return [d for d in data if isinstance(d, dict)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pr_number(value: str | None) -> str | None:
|
|
127
|
+
match = re.search(r"(\d+)\s*$", str(value or ""))
|
|
128
|
+
return match.group(1) if match else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pick_pr(prs: list[dict], branch: str | None, by_number: bool) -> tuple[dict | None, str]:
|
|
132
|
+
"""(the merged PR, "") or (None, why the worktree is kept)."""
|
|
133
|
+
if not prs:
|
|
134
|
+
return None, f"no merged PR found: no PR for branch {branch}" if branch else "no merged PR found: no PR"
|
|
135
|
+
open_pr = next((p for p in prs if str(p.get("state", "")).upper() == "OPEN"), None)
|
|
136
|
+
pick = open_pr or prs[0]
|
|
137
|
+
state = str(pick.get("state", "")).upper() or "UNKNOWN"
|
|
138
|
+
if state != "MERGED":
|
|
139
|
+
return None, f"PR #{pick.get('number')} is {state}"
|
|
140
|
+
head_name = pick.get("headRefName")
|
|
141
|
+
if by_number and branch and head_name and head_name != branch:
|
|
142
|
+
return None, f"PR #{pick.get('number')} is for branch {head_name}, not {branch}"
|
|
143
|
+
return pick, ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# -- worktree ---------------------------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _main_repo(entry: dict, path: Path) -> Path | None:
|
|
149
|
+
"""The repo to list worktrees from: the entry's `repo` when it is a git checkout, else from the path's common dir."""
|
|
150
|
+
for start in (entry.get("repo"), str(path)):
|
|
151
|
+
if not start or not os.path.isdir(start):
|
|
152
|
+
continue
|
|
153
|
+
proc = _git(Path(start), "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
154
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
155
|
+
common = Path(proc.stdout.strip())
|
|
156
|
+
return common.parent if common.name == ".git" else Path(start)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _default_branch(wt: Path) -> str:
|
|
161
|
+
proc = _git(wt, "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD")
|
|
162
|
+
name = proc.stdout.strip() if proc.returncode == 0 else ""
|
|
163
|
+
return name.split("/", 1)[1] if name.startswith("origin/") else "main"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _ref(wt: Path, rev: str) -> str | None:
|
|
167
|
+
proc = _git(wt, "rev-parse", "-q", "--verify", f"{rev}^{{commit}}")
|
|
168
|
+
return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _names(wt: Path, a: str, b: str) -> set[str] | None:
|
|
172
|
+
proc = _git(wt, "diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", a, b)
|
|
173
|
+
return None if proc.returncode != 0 else {n for n in proc.stdout.split("\0") if n}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _check_worktree(entry: dict, path: Path, cwd: str, pr_lookup: Callable) -> tuple[str, str, dict]:
|
|
177
|
+
"""("REMOVE", reason, facts) or ("KEPT"/"GONE", reason, {})."""
|
|
178
|
+
if entry.get("by") == "roadmap-run":
|
|
179
|
+
return "KEPT", "owned by the roadmap runner", {}
|
|
180
|
+
if not os.path.lexists(path):
|
|
181
|
+
return "GONE", "", {}
|
|
182
|
+
try:
|
|
183
|
+
if safety.is_link(os.lstat(path)):
|
|
184
|
+
return "KEPT", "the folder is a junction or symlink", {}
|
|
185
|
+
except OSError as exc:
|
|
186
|
+
return "KEPT", f"cannot inspect it: {exc}", {}
|
|
187
|
+
repo = _main_repo(entry, path)
|
|
188
|
+
if repo is None:
|
|
189
|
+
return "KEPT", "not a registered linked worktree (no git repo found)", {}
|
|
190
|
+
listing = _git(repo, "worktree", "list", "--porcelain")
|
|
191
|
+
if listing.returncode != 0:
|
|
192
|
+
return "KEPT", f"git worktree list failed: {_err(listing)}", {}
|
|
193
|
+
blocks = safety._worktree_blocks(listing.stdout)
|
|
194
|
+
if not blocks:
|
|
195
|
+
return "KEPT", "git listed no worktrees", {}
|
|
196
|
+
main = Path(blocks[0][0][len("worktree "):])
|
|
197
|
+
if _same(path, main):
|
|
198
|
+
return "KEPT", "it is the main checkout", {}
|
|
199
|
+
block = next((b for b in blocks[1:] if _same(path, b[0][len("worktree "):])), None)
|
|
200
|
+
if block is None:
|
|
201
|
+
return "KEPT", f"not a registered linked worktree of {main}", {}
|
|
202
|
+
if _inside(cwd, path):
|
|
203
|
+
return "KEPT", "this session is working in it", {}
|
|
204
|
+
status = _git(path, "status", "--porcelain", "--untracked-files=normal")
|
|
205
|
+
if status.returncode != 0:
|
|
206
|
+
return "KEPT", f"git status failed: {_err(status)}", {}
|
|
207
|
+
if status.stdout.strip():
|
|
208
|
+
return "KEPT", "uncommitted changes", {}
|
|
209
|
+
if any(line == "locked" or line.startswith("locked ") for line in block):
|
|
210
|
+
return "KEPT", "it is locked", {}
|
|
211
|
+
sym = _git(path, "symbolic-ref", "--short", "-q", "HEAD")
|
|
212
|
+
branch = sym.stdout.strip() if sym.returncode == 0 and sym.stdout.strip() else (entry.get("branch") or None)
|
|
213
|
+
default = _default_branch(path)
|
|
214
|
+
if branch and branch == default:
|
|
215
|
+
return "KEPT", f"it is on the default branch {default}", {}
|
|
216
|
+
pr = _pr_number(entry.get("pr"))
|
|
217
|
+
if not branch and not pr:
|
|
218
|
+
return "KEPT", "no merged PR found: no branch and no PR in the record", {}
|
|
219
|
+
try:
|
|
220
|
+
prs = pr_lookup(main, branch, pr)
|
|
221
|
+
except PrLookupError as exc:
|
|
222
|
+
return "KEPT", f"no merged PR found: {exc}", {}
|
|
223
|
+
except Exception as exc: # noqa: BLE001 - any lookup failure keeps the worktree
|
|
224
|
+
return "KEPT", f"no merged PR found: lookup failed: {exc}", {}
|
|
225
|
+
merged, why = _pick_pr(prs, branch, by_number=bool(pr))
|
|
226
|
+
if merged is None:
|
|
227
|
+
return "KEPT", why, {}
|
|
228
|
+
number = str(merged.get("number") or pr or "?")
|
|
229
|
+
head = _ref(path, "HEAD")
|
|
230
|
+
if not head:
|
|
231
|
+
return "KEPT", "cannot read HEAD", {}
|
|
232
|
+
pr_head = str(merged.get("headRefOid") or "").strip().lower()
|
|
233
|
+
if not pr_head or not _ref(path, pr_head): # a reused branch name: without the PR head, nothing proves HEAD landed
|
|
234
|
+
return "KEPT", f"PR #{number} head commit is not in the local repo", {}
|
|
235
|
+
after = _git(path, "rev-list", "--max-count=1", f"{pr_head}..HEAD")
|
|
236
|
+
if after.returncode != 0:
|
|
237
|
+
return "KEPT", f"git rev-list failed: {_err(after)}", {}
|
|
238
|
+
if after.stdout.strip():
|
|
239
|
+
return "KEPT", f"commits after the merged PR #{number}", {}
|
|
240
|
+
fetch = _git(path, "fetch", "--quiet", "--no-tags", "origin",
|
|
241
|
+
f"+refs/heads/{default}:refs/remotes/origin/{default}", timeout=NET_TIMEOUT)
|
|
242
|
+
if fetch.returncode != 0:
|
|
243
|
+
return "KEPT", f"cannot fetch origin/{default}: {_err(fetch)}", {}
|
|
244
|
+
target = f"refs/remotes/origin/{default}"
|
|
245
|
+
base = _git(path, "merge-base", target, "HEAD")
|
|
246
|
+
if base.returncode != 0 or not base.stdout.strip():
|
|
247
|
+
return "KEPT", f"no merge base with origin/{default}", {}
|
|
248
|
+
changed = _names(path, base.stdout.strip(), "HEAD")
|
|
249
|
+
differ_all = _names(path, target, "HEAD")
|
|
250
|
+
if changed is None or differ_all is None:
|
|
251
|
+
return "KEPT", f"git diff against origin/{default} failed", {}
|
|
252
|
+
differ = sorted(changed & differ_all)
|
|
253
|
+
if differ:
|
|
254
|
+
return "KEPT", f"{len(differ)} file(s) differ from origin/{default}: {', '.join(differ[:3])}", {}
|
|
255
|
+
facts = {"repo": main, "branch": branch, "pr": number, "pr_head": pr_head, "head": head}
|
|
256
|
+
return "REMOVE", f"PR #{number} merged, clean, tree equals origin/{default}", facts
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _branch_tip(repo: Path, branch: str) -> str | None:
|
|
260
|
+
proc = _git(repo, "rev-parse", "-q", "--verify", f"refs/heads/{branch}")
|
|
261
|
+
return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _remove_worktree(path: Path, facts: dict) -> tuple[str, str]:
|
|
265
|
+
repo, branch, head, pr_head = facts["repo"], facts["branch"], facts["head"], facts["pr_head"]
|
|
266
|
+
local_tip = _branch_tip(repo, branch) if branch else None
|
|
267
|
+
problem = safety.safe_remove_worktree(repo, path, force=False, env=_env())
|
|
268
|
+
if problem:
|
|
269
|
+
return "FAILED", problem
|
|
270
|
+
notes: list[str] = []
|
|
271
|
+
if branch and local_tip:
|
|
272
|
+
listing = _git(repo, "worktree", "list", "--porcelain")
|
|
273
|
+
if listing.returncode != 0 or f"branch refs/heads/{branch}" in listing.stdout.splitlines():
|
|
274
|
+
notes.append("local branch kept: another worktree has it checked out")
|
|
275
|
+
elif local_tip not in {head, pr_head}:
|
|
276
|
+
notes.append("local branch kept: its tip is not the removed HEAD or the PR head")
|
|
277
|
+
else:
|
|
278
|
+
gone = _git(repo, "branch", "-D", "--", branch)
|
|
279
|
+
notes.append("local branch deleted" if gone.returncode == 0 else f"local branch kept: {_err(gone)}")
|
|
280
|
+
if branch:
|
|
281
|
+
ls = _git(repo, "ls-remote", "--heads", "origin", f"refs/heads/{branch}", timeout=NET_TIMEOUT)
|
|
282
|
+
tips = [ln.split()[0] for ln in ls.stdout.splitlines() if ln.strip()] if ls.returncode == 0 else []
|
|
283
|
+
if ls.returncode != 0:
|
|
284
|
+
notes.append(f"remote branch kept: ls-remote failed: {_err(ls)}")
|
|
285
|
+
elif tips:
|
|
286
|
+
tip = tips[0].lower()
|
|
287
|
+
if tip in {pr_head, head.lower()} - {""}: # only a tip this check proved landed
|
|
288
|
+
try:
|
|
289
|
+
push = _git(repo, *safety.remote_delete_args(branch, tip), timeout=NET_TIMEOUT)
|
|
290
|
+
notes.append("remote branch deleted" if push.returncode == 0
|
|
291
|
+
else f"remote branch kept: {_err(push)}")
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
notes.append(f"remote branch kept: {exc}")
|
|
294
|
+
else:
|
|
295
|
+
notes.append("remote branch kept: its tip moved past the PR head")
|
|
296
|
+
return "REMOVED", "; ".join(notes)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# -- scratch ----------------------------------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def _scan_scratch(path: Path) -> str | None:
|
|
302
|
+
"""None when the folder may go, else why not. Walks every folder (links never followed); errors raise."""
|
|
303
|
+
def fail(exc: OSError) -> None:
|
|
304
|
+
raise exc
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
for top, dirs, files in os.walk(path, topdown=True, onerror=fail, followlinks=False):
|
|
308
|
+
# os.walk does not see a Windows junction as a link: prune every link by lstat, so none is walked
|
|
309
|
+
dirs[:] = [d for d in dirs if not safety.is_link(os.lstat(os.path.join(top, d)))]
|
|
310
|
+
if ".git" not in dirs and ".git" not in files:
|
|
311
|
+
continue
|
|
312
|
+
repo = Path(top)
|
|
313
|
+
if os.path.isfile(repo / ".git"):
|
|
314
|
+
return f"holds a linked git worktree at {repo}; remove it as a worktree"
|
|
315
|
+
status = _git(repo, "status", "--porcelain")
|
|
316
|
+
if status.returncode != 0:
|
|
317
|
+
return f"holds a git repo at {repo} whose status failed: {_err(status)}"
|
|
318
|
+
if status.stdout.strip():
|
|
319
|
+
return f"holds a git repo with uncommitted changes at {repo}"
|
|
320
|
+
local = _git(repo, "rev-list", "--max-count=1", "--all", "--not", "--remotes")
|
|
321
|
+
if local.returncode != 0 or local.stdout.strip():
|
|
322
|
+
return f"holds a git repo with commits or a stash on no remote at {repo}"
|
|
323
|
+
dirs[:] = [d for d in dirs if d != ".git"]
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
return f"cannot scan: {exc}"
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _check_scratch(path: Path, cwd: str) -> tuple[str, str]:
|
|
330
|
+
if not os.path.lexists(path):
|
|
331
|
+
return "GONE", ""
|
|
332
|
+
try:
|
|
333
|
+
if safety.is_link(os.lstat(path)):
|
|
334
|
+
return "KEPT", "the folder is a junction or symlink"
|
|
335
|
+
except OSError as exc:
|
|
336
|
+
return "KEPT", f"cannot inspect it: {exc}"
|
|
337
|
+
if not path.is_dir():
|
|
338
|
+
return "KEPT", "not a folder"
|
|
339
|
+
resolved = Path(os.path.realpath(path))
|
|
340
|
+
if resolved.parent == resolved or _same(resolved, Path.home()) or _inside(Path.home(), resolved):
|
|
341
|
+
return "KEPT", "a root or home folder is never scratch"
|
|
342
|
+
if _inside(cwd, path):
|
|
343
|
+
return "KEPT", "this session is working in it"
|
|
344
|
+
if not os.path.lexists(path / ".git"): # a folder inside a checkout must not take tracked files with it
|
|
345
|
+
inside = _git(path, "rev-parse", "--is-inside-work-tree")
|
|
346
|
+
if inside.returncode == 0 and inside.stdout.strip() == "true":
|
|
347
|
+
tracked = _git(path, "ls-files", "-z", "--", ".")
|
|
348
|
+
if tracked.returncode != 0 or tracked.stdout.strip("\0"):
|
|
349
|
+
return "KEPT", "inside a git checkout and holds tracked files"
|
|
350
|
+
problem = _scan_scratch(path)
|
|
351
|
+
return ("KEPT", problem) if problem else ("REMOVE", "scratch folder of this session")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# -- driver -----------------------------------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def _mark_removed(entry: dict, sid: str, home, pr: str | None = None) -> None:
|
|
357
|
+
by = entry.get("by") if entry.get("by") in caddis_created.BYS else "cli"
|
|
358
|
+
caddis_created.record(entry["path"], entry["kind"], "", pr=pr, status="removed", by=by, session_id=sid,
|
|
359
|
+
home=home)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cleanup(session_id: str, *, apply: bool = False, home: Path | str | None = None,
|
|
363
|
+
pr_lookup: Callable | None = None, cwd: str | Path | None = None) -> list[dict]:
|
|
364
|
+
lookup = pr_lookup or gh_pr_lookup
|
|
365
|
+
here = str(cwd) if cwd is not None else os.getcwd()
|
|
366
|
+
results: list[dict] = []
|
|
367
|
+
for entry in caddis_created.read(session_id, home=home):
|
|
368
|
+
if entry.get("status") != "created":
|
|
369
|
+
continue
|
|
370
|
+
path = Path(entry["path"])
|
|
371
|
+
try:
|
|
372
|
+
if entry["kind"] == "worktree":
|
|
373
|
+
action, reason, facts = _check_worktree(entry, path, here, lookup)
|
|
374
|
+
else:
|
|
375
|
+
(action, reason), facts = _check_scratch(path, here), {}
|
|
376
|
+
if apply and action == "GONE":
|
|
377
|
+
_mark_removed(entry, session_id, home)
|
|
378
|
+
elif apply and action == "REMOVE":
|
|
379
|
+
if entry["kind"] == "worktree":
|
|
380
|
+
action, notes = _remove_worktree(path, facts)
|
|
381
|
+
if action != "REMOVED":
|
|
382
|
+
reason = notes
|
|
383
|
+
elif notes:
|
|
384
|
+
reason = f"{reason}; {notes}"
|
|
385
|
+
else:
|
|
386
|
+
problem = safety.remove_tree(path)
|
|
387
|
+
action, reason = ("FAILED", problem) if problem else ("REMOVED", reason)
|
|
388
|
+
if action == "REMOVED":
|
|
389
|
+
_mark_removed(entry, session_id, home, facts.get("pr"))
|
|
390
|
+
except Exception as exc: # noqa: BLE001 - one bad entry must not stop the others
|
|
391
|
+
action, reason = "FAILED", f"unexpected error: {exc}"
|
|
392
|
+
results.append({"path": str(path), "kind": entry["kind"], "action": action, "reason": reason})
|
|
393
|
+
return results
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def line(result: dict) -> str:
|
|
397
|
+
action, path, reason = result["action"], result["path"], result["reason"]
|
|
398
|
+
if action == "GONE":
|
|
399
|
+
return f"GONE {path}"
|
|
400
|
+
if action in ("REMOVE", "REMOVED"):
|
|
401
|
+
return f"{action} {path}" + (f" ({reason})" if reason else "")
|
|
402
|
+
return f"{action} {path}: {reason}"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def summary(results: list[dict], apply: bool) -> str:
|
|
406
|
+
count = {a: sum(1 for r in results if r["action"] == a) for a in ("REMOVE", "REMOVED", "KEPT", "GONE", "FAILED")}
|
|
407
|
+
head = f"removed {count['REMOVED']}" if apply else f"would remove {count['REMOVE']}"
|
|
408
|
+
return f"[OWN-CLEANUP] {head}, kept {count['KEPT']}, gone {count['GONE']}, failed {count['FAILED']}"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def run(session_id: str | None, apply: bool, as_json: bool) -> int:
|
|
412
|
+
"""The CLI body. Always 0: a handoff must never fail on its cleanup row."""
|
|
413
|
+
if not session_id:
|
|
414
|
+
print("caddis_created: no usable session id; nothing to clean up", file=sys.stderr)
|
|
415
|
+
results: list[dict] = []
|
|
416
|
+
else:
|
|
417
|
+
results = cleanup(session_id, apply=apply)
|
|
418
|
+
if as_json:
|
|
419
|
+
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
420
|
+
return 0
|
|
421
|
+
for result in results:
|
|
422
|
+
print(line(result))
|
|
423
|
+
print(summary(results, apply))
|
|
424
|
+
return 0
|
|
@@ -130,6 +130,7 @@ except ModuleNotFoundError as exc:
|
|
|
130
130
|
sys.path.insert(0, str(_HERE.parent / "claude-harness" / "scripts"))
|
|
131
131
|
import caddis_exit
|
|
132
132
|
|
|
133
|
+
import caddis_created # noqa: E402
|
|
133
134
|
import caddis_notify # noqa: E402
|
|
134
135
|
import caddis_redact # noqa: E402
|
|
135
136
|
import caddis_review # noqa: E402
|
|
@@ -1577,6 +1578,7 @@ def ensure_worktree(repo: Path, slug: str, default: str, root: Path | None = Non
|
|
|
1577
1578
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1578
1579
|
if _git_out(repo, "worktree", "add", "--quiet", "--detach", str(path), f"origin/{default}") is None:
|
|
1579
1580
|
raise _Stop(f"FAILED cannot make the roadmap worktree {path}", caddis_exit.BLOCKED)
|
|
1581
|
+
caddis_created.record(path, "worktree", f"roadmap {slug} runner worktree", repo=repo, by="roadmap-run")
|
|
1580
1582
|
_write_record(_state_file(repo, slug, "worktree"), {"path": str(path), "utc": _iso(datetime.now(timezone.utc))})
|
|
1581
1583
|
return path
|
|
1582
1584
|
|
|
@@ -1606,6 +1608,7 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
|
|
|
1606
1608
|
if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
|
|
1607
1609
|
return f"worktree kept (remove failed): {path}"
|
|
1608
1610
|
_state_file(repo, slug, "worktree").unlink(missing_ok=True)
|
|
1611
|
+
caddis_created.record(path, "worktree", "", repo=repo, status="removed", by="roadmap-run")
|
|
1609
1612
|
return f"worktree removed: {path}"
|
|
1610
1613
|
|
|
1611
1614
|
|
|
@@ -21,8 +21,13 @@ stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
|
21
21
|
worktree_path(repo, short_name, root=None) -> the path for a new worktree (nothing is created)
|
|
22
22
|
main_checkout(repo) -> the main checkout's folder
|
|
23
23
|
|
|
24
|
-
CLI: `python caddis_worktree_root.py [--repo PATH] [--root PATH] <short-name>` prints the path.
|
|
25
|
-
or a folder that is not in a git repo.
|
|
24
|
+
CLI: `python caddis_worktree_root.py [--repo PATH] [--root PATH] [--record --purpose TEXT] <short-name>` prints the path.
|
|
25
|
+
Exit 2 on a bad name or a folder that is not in a git repo.
|
|
26
|
+
|
|
27
|
+
`--record` also appends the path to this session's created list (`caddis_created.py`, by="helper"), so `/handoff` can
|
|
28
|
+
remove the worktree later. The helper creates nothing, so the path is recorded as INTENDED: run `git worktree add` on the
|
|
29
|
+
path it printed. A session with no session id, or a record that cannot be written, only prints a warning on stderr: the
|
|
30
|
+
path is still printed and the exit code is still 0.
|
|
26
31
|
"""
|
|
27
32
|
|
|
28
33
|
from __future__ import annotations
|
|
@@ -96,14 +101,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
96
101
|
ap.add_argument("short_name", help="one folder name, e.g. fix-login")
|
|
97
102
|
ap.add_argument("--repo", default=".", help="any folder of the repo or of one of its worktrees (default: .)")
|
|
98
103
|
ap.add_argument("--root", default=None, help=f"override the root (default: ${ENV_VAR}, then <repo parent>/.worktrees)")
|
|
104
|
+
ap.add_argument("--record", action="store_true", help="also record the path in this session's created list")
|
|
105
|
+
ap.add_argument("--purpose", default="", help="with --record: why the worktree is made")
|
|
99
106
|
args = ap.parse_args(argv)
|
|
100
107
|
try:
|
|
101
|
-
|
|
108
|
+
path = worktree_path(args.repo, args.short_name, args.root)
|
|
109
|
+
repo = main_checkout(args.repo) if args.record else None
|
|
102
110
|
except (ValueError, NotARepoError) as exc:
|
|
103
111
|
print(f"error: {exc}", file=sys.stderr)
|
|
104
112
|
return 2
|
|
113
|
+
print(path)
|
|
114
|
+
if args.record:
|
|
115
|
+
_record(path, repo, args.purpose)
|
|
105
116
|
return 0
|
|
106
117
|
|
|
107
118
|
|
|
119
|
+
def _record(path: Path, repo: Path | None, purpose: str) -> None:
|
|
120
|
+
"""Append `path` to the session's created list. A failure only warns: the caller still has its path."""
|
|
121
|
+
try:
|
|
122
|
+
import caddis_created
|
|
123
|
+
done = caddis_created.record(path, "worktree", purpose, repo=repo, by="helper")
|
|
124
|
+
except Exception: # noqa: BLE001 - recording must never break a command that only wants the path
|
|
125
|
+
done = False
|
|
126
|
+
if not done:
|
|
127
|
+
print(f"caddis_worktree_root: not recorded (no usable session id, or the record could not be written): {path}",
|
|
128
|
+
file=sys.stderr)
|
|
129
|
+
|
|
130
|
+
|
|
108
131
|
if __name__ == "__main__":
|
|
109
132
|
sys.exit(main())
|
|
@@ -37,10 +37,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
37
37
|
|
|
38
38
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
39
39
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
40
|
-
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
40
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
|
|
41
41
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
42
42
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
43
|
-
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
43
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name> --record --purpose "test <name>")" origin/<branch>` (for the default branch,
|
|
44
44
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
45
45
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
46
46
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -50,7 +50,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
50
50
|
|
|
51
51
|
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
52
52
|
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
53
|
-
cleanup can empty it.
|
|
53
|
+
cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
|
|
54
|
+
session's created list (`caddis_created.py`), so `/handoff` can remove the worktree later. The caddis guard checks only the `Shared checkout: yes` line in
|
|
54
55
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
55
56
|
<!-- /checkout-safety -->
|
|
56
57
|
|
|
@@ -39,6 +39,8 @@ them into its commit, so check first, before Step 1.
|
|
|
39
39
|
- Step 6: retired; nothing to skip (row 8 reports `skipped: retired (see the 3-store model)`).
|
|
40
40
|
- Step 6b: skip `session_state.py prune` (report `skipped: shared checkout`).
|
|
41
41
|
- Step 6c: run `--reindex` in the worktree (it edits the tracked `DOC-MAP.md`).
|
|
42
|
+
- Step 6d: runs as written. It never writes a file in this checkout; it only removes worktrees and
|
|
43
|
+
scratch folders this session recorded.
|
|
42
44
|
- The relay stays in this checkout: `.caddis/relay/` is local and gitignored.
|
|
43
45
|
After Step 6c, run:
|
|
44
46
|
```
|
|
@@ -51,9 +53,9 @@ them into its commit, so check first, before Step 1.
|
|
|
51
53
|
|
|
52
54
|
## Checklist
|
|
53
55
|
The handoff is a fixed checklist. Every row ends as `done`, `skipped: <reason>` or `failed: <error>`, and
|
|
54
|
-
Step 8 prints all
|
|
55
|
-
Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list so the row numbers
|
|
56
|
-
|
|
56
|
+
Step 8 prints all 14 in this order. Never drop a row from the report, even when its step did not apply.
|
|
57
|
+
Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list so the row numbers do not
|
|
58
|
+
move for a fleet that mixes caddis versions, and each always reports
|
|
57
59
|
`skipped: retired (see the 3-store model)`. A later release removes them with their scripts.
|
|
58
60
|
|
|
59
61
|
| # | Row | What it is | Shared checkout (Step 0) |
|
|
@@ -71,6 +73,7 @@ Rows 3 and 8 are retired (the 3-store model, 21 Oct 2026): they stay in the list
|
|
|
71
73
|
| 11 | `state-prune` | Step 6b: `session_state.py prune` | `skipped: shared checkout` |
|
|
72
74
|
| 12 | `prune-list` | Step 6b: `caddis_tidy.py --prune`, list only | runs (lists, never applies) |
|
|
73
75
|
| 13 | `handover-check` | Step 5 | runs |
|
|
76
|
+
| 14 | `own-cleanup` | Step 6d: `caddis_created.py cleanup --apply` removes this session's merged, clean worktrees and scratch folders; lists the rest with a reason | runs (only this session's recorded worktrees and scratch) |
|
|
74
77
|
|
|
75
78
|
`tidy-apply` and `state-prune` run only on `SHARED: no`. Handoff never runs
|
|
76
79
|
`caddis_tidy.py --prune --apply` on its own, in any checkout; only the user can ask for it (Step 6b). In a
|
|
@@ -346,6 +349,27 @@ Then, when the repo has a roadmap (`.caddis/roadmaps/`), re-render its page so p
|
|
|
346
349
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_roadmap.py" render --all
|
|
347
350
|
```
|
|
348
351
|
|
|
352
|
+
## Step 6d — remove this session's own worktrees and scratch folders
|
|
353
|
+
Checklist row 14, `own-cleanup`. It runs in every checkout, a shared one too, after the Step 0 `finish`
|
|
354
|
+
(falls back to `scripts/caddis_created.py` in a source checkout; if the script is missing, or exits 2 because
|
|
355
|
+
it has no `cleanup`, the row is `skipped: not installed`):
|
|
356
|
+
```
|
|
357
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_created.py" cleanup --session-id <session-id> --apply
|
|
358
|
+
```
|
|
359
|
+
It reads only this session's record (`~/.caddis/sessions/<session-id>.created.jsonl`). A worktree goes only
|
|
360
|
+
when its PR is merged, `git status` is clean, it has no commits after the PR head, and every file its branch
|
|
361
|
+
changed equals `origin/<default>` (so a squash merge counts). Then its local and remote branch go too: each
|
|
362
|
+
only while its tip is still the merged work. A scratch folder goes after a full scan, links first.
|
|
363
|
+
|
|
364
|
+
It never removes: another session's worktree (it is not in this record), a roadmap runner's worktree, the
|
|
365
|
+
worktree this session is working in, one with uncommitted changes (untracked files count), one whose PR is
|
|
366
|
+
not merged or not found, one whose tree differs from `origin/<default>`, or anything through a
|
|
367
|
+
junction or symlink (a link is removed as a link; its target stays). It prints one line per entry (`REMOVED`, `KEPT
|
|
368
|
+
<path>: <reason>`, `GONE` or `FAILED <path>: <problem>`) and ends with
|
|
369
|
+
`[OWN-CLEANUP] removed <n>, kept <n>, gone <n>, failed <n>`. Include that summary line and each `KEPT` and
|
|
370
|
+
`FAILED` line in your report. It always exits 0: a kept or failed entry is not a failure of the row, which is
|
|
371
|
+
`done` when the script ran.
|
|
372
|
+
|
|
349
373
|
## Step 7 — push prompt (interactive only)
|
|
350
374
|
If this is an interactive session, ask the user every time — never reuse an earlier answer:
|
|
351
375
|
`Push this note for another machine? (y/N)`
|
|
@@ -376,6 +400,7 @@ Print this block, every row filled with `done`, `skipped: <reason>` or `failed:
|
|
|
376
400
|
11 state-prune <same three forms>
|
|
377
401
|
12 prune-list <same three forms>
|
|
378
402
|
13 handover-check <same three forms>
|
|
403
|
+
14 own-cleanup <same three forms>
|
|
379
404
|
Shared checkout: yes|no <reason>
|
|
380
405
|
```
|
|
381
406
|
|
|
@@ -43,10 +43,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
43
43
|
|
|
44
44
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
45
45
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
46
|
-
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
46
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
|
|
47
47
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
48
48
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
49
|
-
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
49
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name> --record --purpose "test <name>")" origin/<branch>` (for the default branch,
|
|
50
50
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
51
51
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
52
52
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -56,7 +56,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
56
56
|
|
|
57
57
|
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
58
58
|
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
59
|
-
cleanup can empty it.
|
|
59
|
+
cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
|
|
60
|
+
session's created list (`caddis_created.py`), so `/handoff` can remove the worktree later. The caddis guard checks only the `Shared checkout: yes` line in
|
|
60
61
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
61
62
|
<!-- /checkout-safety -->
|
|
62
63
|
|
|
@@ -42,10 +42,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
42
42
|
|
|
43
43
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
44
44
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
45
|
-
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
45
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
|
|
46
46
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
47
47
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
48
|
-
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
48
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name> --record --purpose "test <name>")" origin/<branch>` (for the default branch,
|
|
49
49
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
50
50
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
51
51
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -55,7 +55,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
55
55
|
|
|
56
56
|
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
57
57
|
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
58
|
-
cleanup can empty it.
|
|
58
|
+
cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
|
|
59
|
+
session's created list (`caddis_created.py`), so `/handoff` can remove the worktree later. The caddis guard checks only the `Shared checkout: yes` line in
|
|
59
60
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
60
61
|
<!-- /checkout-safety -->
|
|
61
62
|
|
|
@@ -41,10 +41,10 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
41
41
|
|
|
42
42
|
- **On `SHARED: no`,** do the steps as written, in this checkout.
|
|
43
43
|
- **On `SHARED: yes`,** never move this checkout. Leave its changes as they are, and report them. Instead:
|
|
44
|
-
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name>)" <branch>`, then
|
|
44
|
+
- **Rebase:** `git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" rebase-<name> --record --purpose "rebase <name>")" <branch>`, then
|
|
45
45
|
`git -C <worktree> rebase origin/<default>` and `git -C <worktree> push --force-with-lease origin HEAD:<branch>`.
|
|
46
46
|
- **Pull a branch to test it:** `git fetch origin`, then
|
|
47
|
-
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name>)" origin/<branch>` (for the default branch,
|
|
47
|
+
`git worktree add --detach "$(python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_worktree_root.py" test-<name> --record --purpose "test <name>")" origin/<branch>` (for the default branch,
|
|
48
48
|
`origin/<default>`), and run the gates there. Install dependencies fresh; never link `node_modules` or a venv.
|
|
49
49
|
- **Clean up after a merge:** do not switch or pull. Run `git fetch --prune`, then
|
|
50
50
|
`git fetch origin <default>:<default>`: it fast-forwards the default branch with no checkout. Git refuses when a
|
|
@@ -54,7 +54,8 @@ It prints `SHARED: yes <reason>` or `SHARED: no` and exits 0. It says yes when t
|
|
|
54
54
|
|
|
55
55
|
`<name>` is the branch name with each `/` written as `-`. `caddis_worktree_root.py` prints the path of a new worktree:
|
|
56
56
|
`$CADDIS_WORKTREE_ROOT/<repo>/<short-name>`, else `<parent of the main checkout>/.worktrees/<repo>/<short-name>`. Never `%TEMP%`: Windows
|
|
57
|
-
cleanup can empty it.
|
|
57
|
+
cleanup can empty it. `--record --purpose "<why>"` also writes the path to this
|
|
58
|
+
session's created list (`caddis_created.py`), so `/handoff` can remove the worktree later. The caddis guard checks only the `Shared checkout: yes` line in
|
|
58
59
|
`AGENTS.md`, not a dirty tree: with that line, it asks before a tree move, and denies it in a headless session.
|
|
59
60
|
<!-- /checkout-safety -->
|
|
60
61
|
|
|
@@ -39,10 +39,16 @@ If none is true, stop here and use `git switch -c`.
|
|
|
39
39
|
script `caddis_worktree_root.py` prints the path; use it so the rule lives in one place:
|
|
40
40
|
|
|
41
41
|
```powershell
|
|
42
|
-
$wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> # run in the repo; prints the path
|
|
42
|
+
$wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> --record --purpose "<why>" # run in the repo; prints the path
|
|
43
43
|
git worktree add -b <branch> $wt origin/<default>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
`--record` also writes the path to this session's created list (`caddis_created.py`), so `/handoff` can remove the
|
|
47
|
+
worktree later. Always record it, subagents most of all: their tool calls are in a separate transcript that the
|
|
48
|
+
Stop hook does not read. If you create a worktree without the helper, run
|
|
49
|
+
`python "<caddis scripts>\caddis_created.py" record --path <path> --kind worktree --purpose "<why>"` right after
|
|
50
|
+
`git worktree add`.
|
|
51
|
+
|
|
46
52
|
`<caddis scripts>` is the `scripts` folder of the installed caddis plugin. Do not create worktrees inside the
|
|
47
53
|
repo. An existing worktree stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
48
54
|
4. State the full path of the worktree. Work only there, and do not switch branches in it.
|
|
@@ -39,10 +39,16 @@ If none is true, stop here and use `git switch -c`.
|
|
|
39
39
|
script `caddis_worktree_root.py` prints the path; use it so the rule lives in one place:
|
|
40
40
|
|
|
41
41
|
```powershell
|
|
42
|
-
$wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> # run in the repo; prints the path
|
|
42
|
+
$wt = python "<caddis scripts>\caddis_worktree_root.py" <short-name> --record --purpose "<why>" # run in the repo; prints the path
|
|
43
43
|
git worktree add -b <branch> $wt origin/<default>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
`--record` also writes the path to this session's created list (`caddis_created.py`), so `/handoff` can remove the
|
|
47
|
+
worktree later. Always record it, subagents most of all: their tool calls are in a separate transcript that the
|
|
48
|
+
Stop hook does not read. If you create a worktree without the helper, run
|
|
49
|
+
`python "<caddis scripts>\caddis_created.py" record --path <path> --kind worktree --purpose "<why>"` right after
|
|
50
|
+
`git worktree add`.
|
|
51
|
+
|
|
46
52
|
`<caddis scripts>` is the `scripts` folder of the installed caddis plugin. Do not create worktrees inside the
|
|
47
53
|
repo. An existing worktree stays where it is: moving it breaks a venv, which keeps absolute paths.
|
|
48
54
|
4. State the full path of the worktree. Work only there, and do not switch branches in it.
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED