worktree-janitor-cli 1.0.0__py3-none-any.whl
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.
- worktree_janitor.py +1070 -0
- worktree_janitor_cli-1.0.0.dist-info/METADATA +511 -0
- worktree_janitor_cli-1.0.0.dist-info/RECORD +7 -0
- worktree_janitor_cli-1.0.0.dist-info/WHEEL +5 -0
- worktree_janitor_cli-1.0.0.dist-info/entry_points.txt +2 -0
- worktree_janitor_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- worktree_janitor_cli-1.0.0.dist-info/top_level.txt +1 -0
worktree_janitor.py
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""worktree-janitor — a read-only inventory of a git
|
|
3
|
+
repo's worktrees and branches, with a safe cleanup PLAN
|
|
4
|
+
it never executes.
|
|
5
|
+
|
|
6
|
+
Pure Python standard library. Local only: no network, no
|
|
7
|
+
uploads, no accounts. The tool shells out to git — but
|
|
8
|
+
only ever to git's read-only query commands:
|
|
9
|
+
|
|
10
|
+
git worktree list --porcelain
|
|
11
|
+
git status --porcelain=v1 --branch (per worktree)
|
|
12
|
+
git branch --format=... / --merged / --no-merged
|
|
13
|
+
git log -1 / git log <range> (counts + dates)
|
|
14
|
+
git stash list
|
|
15
|
+
|
|
16
|
+
That list is enforced in code, not just promised: every
|
|
17
|
+
git invocation funnels through _run_git(), which refuses
|
|
18
|
+
any argv carrying a mutating verb or flag before git is
|
|
19
|
+
even started. All calls also run with GIT_OPTIONAL_LOCKS=0
|
|
20
|
+
so git itself does not take locks or refresh the index —
|
|
21
|
+
the scan changes nothing, not even a timestamp in .git.
|
|
22
|
+
|
|
23
|
+
Why this exists: parallel AI coding agents spawn git
|
|
24
|
+
worktrees constantly — one per session, per task, per
|
|
25
|
+
experiment. Headless runs leave theirs behind, locked.
|
|
26
|
+
Forgotten worktrees pile up, each pinning its branch,
|
|
27
|
+
each a folder of disk, and the only copy of some piece
|
|
28
|
+
of work may live in one of them. Deleting blindly loses
|
|
29
|
+
work; keeping everything rots the repo. The manual ritual
|
|
30
|
+
every guide prescribes — `git worktree list`, then walk
|
|
31
|
+
each folder checking status by hand — is exactly the
|
|
32
|
+
chore this tool makes one deterministic pass.
|
|
33
|
+
|
|
34
|
+
What it reports, per worktree: path, branch (or detached
|
|
35
|
+
HEAD), HEAD commit, dirty/clean with file counts, ahead /
|
|
36
|
+
behind vs its upstream (or commit counts vs the default
|
|
37
|
+
branch when no upstream is configured — the basis is
|
|
38
|
+
always named), last-commit age in days from git's own
|
|
39
|
+
committer timestamps, and the locked / prunable state
|
|
40
|
+
git itself reports. Each worktree gets one verdict:
|
|
41
|
+
|
|
42
|
+
SAFE_TO_REMOVE clean, branch merged into the default
|
|
43
|
+
branch, no unpushed commits, not
|
|
44
|
+
locked, not the main worktree, not
|
|
45
|
+
the worktree the scan was run from —
|
|
46
|
+
or a prunable registration whose
|
|
47
|
+
directory is already gone.
|
|
48
|
+
REVIEW something in it exists nowhere else
|
|
49
|
+
or cannot be verified: uncommitted
|
|
50
|
+
changes, untracked files, an unmerged
|
|
51
|
+
branch, unpushed commits, a detached
|
|
52
|
+
HEAD, or an unreadable status. The
|
|
53
|
+
exact reason is always printed.
|
|
54
|
+
KEEP the main worktree, a worktree git
|
|
55
|
+
reports as locked, the worktree the
|
|
56
|
+
scan was run from, or a bare repo.
|
|
57
|
+
|
|
58
|
+
Local branches are inventoried too (merged or not, tip
|
|
59
|
+
age, checked out where, upstream, unpushed count), and
|
|
60
|
+
merged branches whose last commit is at least
|
|
61
|
+
--stale-days old (default 30) and which are not checked
|
|
62
|
+
out anywhere are listed separately as STALE.
|
|
63
|
+
|
|
64
|
+
The `plan` subcommand prints the exact git commands that
|
|
65
|
+
WOULD remove the SAFE worktrees, prune dead
|
|
66
|
+
registrations, and delete the stale branches with
|
|
67
|
+
`git branch -d` (lowercase — git itself refuses if a
|
|
68
|
+
branch turns out unmerged; the plan never prints -D).
|
|
69
|
+
The commands are output text. This tool contains no code
|
|
70
|
+
path that executes them.
|
|
71
|
+
|
|
72
|
+
Honesty rules: every fact comes from git's own output.
|
|
73
|
+
When a check cannot be made — no default branch can be
|
|
74
|
+
determined, an upstream ref is not present locally — the
|
|
75
|
+
field says so and the check is named in checks_skipped,
|
|
76
|
+
never silently passed. A worktree whose merge or
|
|
77
|
+
unpushed status is unknown is REVIEW, never SAFE.
|
|
78
|
+
|
|
79
|
+
Exit codes: 0 = nothing to clean (every worktree KEEP,
|
|
80
|
+
no stale branches) · 1 = cleanup candidates or REVIEW
|
|
81
|
+
items exist · 2 = ERROR (not a git repo, git missing,
|
|
82
|
+
bad arguments) — a clean message, never a traceback;
|
|
83
|
+
with --json the error is {"error": ...}.
|
|
84
|
+
"""
|
|
85
|
+
import argparse
|
|
86
|
+
import json
|
|
87
|
+
import os
|
|
88
|
+
import re
|
|
89
|
+
import shlex
|
|
90
|
+
import shutil
|
|
91
|
+
import subprocess
|
|
92
|
+
import sys
|
|
93
|
+
import time
|
|
94
|
+
from datetime import datetime, timezone
|
|
95
|
+
from pathlib import Path
|
|
96
|
+
|
|
97
|
+
__version__ = "1.0.0"
|
|
98
|
+
TOOL = "worktree-janitor"
|
|
99
|
+
|
|
100
|
+
SAFE = "SAFE_TO_REMOVE"
|
|
101
|
+
REVIEW = "REVIEW"
|
|
102
|
+
KEEP = "KEEP"
|
|
103
|
+
_VERDICT_ORDER = [REVIEW, SAFE, KEEP]
|
|
104
|
+
|
|
105
|
+
DEFAULT_STALE_DAYS = 30
|
|
106
|
+
_ZERO_SHA = "0" * 40
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ToolError(Exception):
|
|
110
|
+
"""A clean, user-facing failure (printed as ERROR,
|
|
111
|
+
exit 2)."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------------
|
|
115
|
+
# The read-only gate: every git call funnels through here
|
|
116
|
+
# --------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
# Verbs worktree-janitor may run at all. worktree/stash are
|
|
119
|
+
# further restricted to their "list" form below.
|
|
120
|
+
_ALLOWED_VERBS = {"worktree", "status", "branch", "log",
|
|
121
|
+
"stash"}
|
|
122
|
+
|
|
123
|
+
# Any argv element from this set — anywhere in any command
|
|
124
|
+
# — aborts the call before git starts. These are the
|
|
125
|
+
# mutating verbs and flags of the commands this tool uses.
|
|
126
|
+
_FORBIDDEN_TOKENS = {
|
|
127
|
+
"add", "checkout", "switch", "clean", "commit",
|
|
128
|
+
"restore", "reset", "update-ref", "rebase", "merge",
|
|
129
|
+
"push", "pull", "fetch", "lock", "unlock", "move",
|
|
130
|
+
"repair", "apply", "rm",
|
|
131
|
+
"-d", "-D", "-m", "-M", "-c", "-C", "-f", "--force",
|
|
132
|
+
"--delete", "--move", "--copy", "--set-upstream-to",
|
|
133
|
+
"--unset-upstream", "--edit-description", "--prune",
|
|
134
|
+
"--remove", "--dry-run",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _assert_read_only(args):
|
|
139
|
+
"""Refuse any git argv that is not a pure query. This
|
|
140
|
+
is the enforcement behind the read-only promise: the
|
|
141
|
+
plan's command strings are built as text elsewhere and
|
|
142
|
+
never pass through here — only real invocations do."""
|
|
143
|
+
if not args or args[0] not in _ALLOWED_VERBS:
|
|
144
|
+
raise AssertionError(
|
|
145
|
+
f"git verb not on the read-only allowlist: "
|
|
146
|
+
f"{args[:1]}")
|
|
147
|
+
if args[0] in ("worktree", "stash") and (
|
|
148
|
+
len(args) < 2 or args[1] != "list"):
|
|
149
|
+
raise AssertionError(
|
|
150
|
+
f"git {args[0]} is only ever run as "
|
|
151
|
+
f"'{args[0]} list'")
|
|
152
|
+
for tok in args:
|
|
153
|
+
if tok in _FORBIDDEN_TOKENS:
|
|
154
|
+
raise AssertionError(
|
|
155
|
+
f"mutating token refused: {tok!r}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def git_exe():
|
|
159
|
+
return shutil.which("git")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_git(cwd, args):
|
|
163
|
+
"""Run one allowlisted git query in CWD. Returns
|
|
164
|
+
(returncode, stdout, stderr). GIT_OPTIONAL_LOCKS=0
|
|
165
|
+
keeps git from taking locks or refreshing the index,
|
|
166
|
+
so a scan writes nothing — not even inside .git."""
|
|
167
|
+
_assert_read_only(args)
|
|
168
|
+
exe = git_exe()
|
|
169
|
+
if exe is None:
|
|
170
|
+
raise ToolError(
|
|
171
|
+
"git was not found on PATH — worktree-janitor "
|
|
172
|
+
"reads git's own records and cannot work "
|
|
173
|
+
"without it")
|
|
174
|
+
env = dict(os.environ)
|
|
175
|
+
env["GIT_OPTIONAL_LOCKS"] = "0"
|
|
176
|
+
env["LC_ALL"] = "C"
|
|
177
|
+
proc = subprocess.run(
|
|
178
|
+
[exe, "-C", str(cwd)] + list(args),
|
|
179
|
+
capture_output=True, text=True, env=env)
|
|
180
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def git_query(cwd, args, what):
|
|
184
|
+
"""run_git for a query that must succeed; a failure is
|
|
185
|
+
a clean ToolError naming WHAT was being read."""
|
|
186
|
+
rc, out, err = run_git(cwd, args)
|
|
187
|
+
if rc != 0:
|
|
188
|
+
first = (err.strip().splitlines() or [""])[0]
|
|
189
|
+
raise ToolError(f"could not read {what}: {first}")
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------
|
|
194
|
+
# Parsers for git's porcelain output
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def parse_worktree_list(text):
|
|
198
|
+
"""git worktree list --porcelain -> list of dicts:
|
|
199
|
+
path, head, branch (short name or None), detached,
|
|
200
|
+
bare, locked, lock_reason, prunable,
|
|
201
|
+
prunable_reason."""
|
|
202
|
+
worktrees = []
|
|
203
|
+
cur = None
|
|
204
|
+
for line in text.splitlines():
|
|
205
|
+
if not line.strip():
|
|
206
|
+
if cur is not None:
|
|
207
|
+
worktrees.append(cur)
|
|
208
|
+
cur = None
|
|
209
|
+
continue
|
|
210
|
+
if cur is None:
|
|
211
|
+
cur = {"path": None, "head": None,
|
|
212
|
+
"branch": None, "detached": False,
|
|
213
|
+
"bare": False, "locked": False,
|
|
214
|
+
"lock_reason": None, "prunable": False,
|
|
215
|
+
"prunable_reason": None}
|
|
216
|
+
if line.startswith("worktree "):
|
|
217
|
+
cur["path"] = line[len("worktree "):]
|
|
218
|
+
elif line.startswith("HEAD "):
|
|
219
|
+
cur["head"] = line[len("HEAD "):]
|
|
220
|
+
elif line.startswith("branch "):
|
|
221
|
+
ref = line[len("branch "):]
|
|
222
|
+
prefix = "refs/heads/"
|
|
223
|
+
cur["branch"] = (ref[len(prefix):]
|
|
224
|
+
if ref.startswith(prefix)
|
|
225
|
+
else ref)
|
|
226
|
+
elif line == "detached":
|
|
227
|
+
cur["detached"] = True
|
|
228
|
+
elif line == "bare":
|
|
229
|
+
cur["bare"] = True
|
|
230
|
+
elif line == "locked" or line.startswith("locked "):
|
|
231
|
+
cur["locked"] = True
|
|
232
|
+
cur["lock_reason"] = (line[len("locked "):]
|
|
233
|
+
if line.startswith("locked ")
|
|
234
|
+
else None)
|
|
235
|
+
elif line == "prunable" or line.startswith("prunable "):
|
|
236
|
+
cur["prunable"] = True
|
|
237
|
+
cur["prunable_reason"] = (
|
|
238
|
+
line[len("prunable "):]
|
|
239
|
+
if line.startswith("prunable ") else None)
|
|
240
|
+
if cur is not None:
|
|
241
|
+
worktrees.append(cur)
|
|
242
|
+
return worktrees
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_HEADER_RE = re.compile(
|
|
246
|
+
r"^## (?:No commits yet on (?P<fresh>.+)|"
|
|
247
|
+
r"HEAD \(no branch\)|"
|
|
248
|
+
r"(?P<branch>[^.\s]+(?:\.[^.\s]+)*)"
|
|
249
|
+
r"(?:\.\.\.(?P<upstream>\S+))?"
|
|
250
|
+
r"(?: \[(?P<track>[^\]]+)\])?)$")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_status(text):
|
|
254
|
+
"""git status --porcelain=v1 --branch -> dict:
|
|
255
|
+
branch, upstream, ahead, behind, detached, no_commits,
|
|
256
|
+
changed (tracked files with staged or unstaged
|
|
257
|
+
changes), staged, unstaged, untracked."""
|
|
258
|
+
st = {"branch": None, "upstream": None, "ahead": None,
|
|
259
|
+
"behind": None, "detached": False,
|
|
260
|
+
"no_commits": False, "changed": 0, "staged": 0,
|
|
261
|
+
"unstaged": 0, "untracked": 0}
|
|
262
|
+
for line in text.splitlines():
|
|
263
|
+
if line.startswith("## "):
|
|
264
|
+
m = _HEADER_RE.match(line)
|
|
265
|
+
if m:
|
|
266
|
+
if m.group("fresh") is not None:
|
|
267
|
+
st["no_commits"] = True
|
|
268
|
+
st["branch"] = m.group("fresh")
|
|
269
|
+
elif m.group("branch") is None:
|
|
270
|
+
st["detached"] = True
|
|
271
|
+
else:
|
|
272
|
+
st["branch"] = m.group("branch")
|
|
273
|
+
st["upstream"] = m.group("upstream")
|
|
274
|
+
track = m.group("track") or ""
|
|
275
|
+
ma = re.search(r"ahead (\d+)", track)
|
|
276
|
+
mb = re.search(r"behind (\d+)", track)
|
|
277
|
+
if ma:
|
|
278
|
+
st["ahead"] = int(ma.group(1))
|
|
279
|
+
if mb:
|
|
280
|
+
st["behind"] = int(mb.group(1))
|
|
281
|
+
if st["upstream"] is not None:
|
|
282
|
+
if st["ahead"] is None:
|
|
283
|
+
st["ahead"] = 0
|
|
284
|
+
if st["behind"] is None:
|
|
285
|
+
st["behind"] = 0
|
|
286
|
+
continue
|
|
287
|
+
if len(line) < 2:
|
|
288
|
+
continue
|
|
289
|
+
x, y = line[0], line[1]
|
|
290
|
+
if x == "?" and y == "?":
|
|
291
|
+
st["untracked"] += 1
|
|
292
|
+
continue
|
|
293
|
+
if x == "!" and y == "!":
|
|
294
|
+
continue
|
|
295
|
+
st["changed"] += 1
|
|
296
|
+
if x not in " ?":
|
|
297
|
+
st["staged"] += 1
|
|
298
|
+
if y not in " ?":
|
|
299
|
+
st["unstaged"] += 1
|
|
300
|
+
return st
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --------------------------------------------------------------------------
|
|
304
|
+
# Small git fact helpers
|
|
305
|
+
# --------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def _commit_info(repo, ref):
|
|
308
|
+
"""{sha, unix, date, age_days, subject} for REF's tip
|
|
309
|
+
commit, or None when REF has no commit (unborn branch,
|
|
310
|
+
empty repo) or git cannot read it. Dates are the
|
|
311
|
+
committer timestamps git records."""
|
|
312
|
+
if not ref or ref == _ZERO_SHA:
|
|
313
|
+
return None
|
|
314
|
+
rc, out, _ = run_git(
|
|
315
|
+
repo, ["log", "-1", "--format=%H%x1f%ct%x1f%s", ref])
|
|
316
|
+
if rc != 0 or not out.strip():
|
|
317
|
+
return None
|
|
318
|
+
sha, ct, subject = out.rstrip("\n").split("\x1f", 2)
|
|
319
|
+
return {"sha": sha, "unix": int(ct), "subject": subject}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _age(info, now):
|
|
323
|
+
if info is None:
|
|
324
|
+
return None
|
|
325
|
+
age = max(0, int((now - info["unix"]) // 86400))
|
|
326
|
+
info["age_days"] = age
|
|
327
|
+
info["date"] = datetime.fromtimestamp(
|
|
328
|
+
info["unix"], tz=timezone.utc).strftime("%Y-%m-%d")
|
|
329
|
+
return age
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _range_count(repo, range_spec):
|
|
333
|
+
"""Number of commits in RANGE_SPEC, or None when git
|
|
334
|
+
cannot evaluate it (e.g. the upstream ref is not
|
|
335
|
+
present locally)."""
|
|
336
|
+
rc, out, _ = run_git(
|
|
337
|
+
repo, ["log", "--format=%H", range_spec])
|
|
338
|
+
if rc != 0:
|
|
339
|
+
return None
|
|
340
|
+
return sum(1 for ln in out.splitlines() if ln.strip())
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --------------------------------------------------------------------------
|
|
344
|
+
# The scan
|
|
345
|
+
# --------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def scan(d, stale_days):
|
|
348
|
+
"""Full inventory + verdicts for the repo containing
|
|
349
|
+
DIR D (any folder inside any of its worktrees works —
|
|
350
|
+
git resolves the whole repo from it)."""
|
|
351
|
+
d = Path(d)
|
|
352
|
+
now = int(time.time())
|
|
353
|
+
skipped = []
|
|
354
|
+
|
|
355
|
+
raw = git_query(d, ["worktree", "list", "--porcelain"],
|
|
356
|
+
"the worktree list (is this a git "
|
|
357
|
+
"repository?)")
|
|
358
|
+
raw_wts = parse_worktree_list(raw)
|
|
359
|
+
if not raw_wts:
|
|
360
|
+
raise ToolError("git reported no worktrees — is "
|
|
361
|
+
"this a git repository?")
|
|
362
|
+
repo_root = Path(raw_wts[0]["path"])
|
|
363
|
+
|
|
364
|
+
# The worktree the scan was run from: the longest
|
|
365
|
+
# worktree path that is DIR itself or a parent of it.
|
|
366
|
+
try:
|
|
367
|
+
here = str(Path(d).resolve())
|
|
368
|
+
except OSError:
|
|
369
|
+
here = str(d)
|
|
370
|
+
current_path = None
|
|
371
|
+
for w in raw_wts:
|
|
372
|
+
p = str(Path(w["path"]).resolve()) \
|
|
373
|
+
if w["path"] else None
|
|
374
|
+
if p and (here == p or here.startswith(p + os.sep)):
|
|
375
|
+
if current_path is None or len(p) > len(
|
|
376
|
+
current_path):
|
|
377
|
+
current_path = p
|
|
378
|
+
|
|
379
|
+
# Local branch inventory: name, upstream, tip — one
|
|
380
|
+
# branch listing call with for-each-ref atoms.
|
|
381
|
+
branches = []
|
|
382
|
+
fmt = "%(refname:short)%09%(upstream:short)%09%(objectname)"
|
|
383
|
+
bout = git_query(repo_root, ["branch", f"--format={fmt}"],
|
|
384
|
+
"the local branch list")
|
|
385
|
+
for line in bout.splitlines():
|
|
386
|
+
if not line.strip():
|
|
387
|
+
continue
|
|
388
|
+
parts = line.split("\t")
|
|
389
|
+
branches.append({
|
|
390
|
+
"name": parts[0],
|
|
391
|
+
"upstream": parts[1] or None
|
|
392
|
+
if len(parts) > 1 else None,
|
|
393
|
+
"tip": parts[2] if len(parts) > 2 else None})
|
|
394
|
+
by_name = {b["name"]: b for b in branches}
|
|
395
|
+
|
|
396
|
+
# Default branch: the branch of the main worktree —
|
|
397
|
+
# git's own record. Fallbacks are named, never silent.
|
|
398
|
+
default = None
|
|
399
|
+
basis = None
|
|
400
|
+
if raw_wts[0]["branch"]:
|
|
401
|
+
default = raw_wts[0]["branch"]
|
|
402
|
+
basis = "the main worktree's checked-out branch"
|
|
403
|
+
elif "main" in by_name:
|
|
404
|
+
default = "main"
|
|
405
|
+
basis = "fallback: a local branch named 'main' exists"
|
|
406
|
+
elif "master" in by_name:
|
|
407
|
+
default = "master"
|
|
408
|
+
basis = "fallback: a local branch named 'master' exists"
|
|
409
|
+
if default is None:
|
|
410
|
+
skipped.append(
|
|
411
|
+
"merge status and unpushed counts: no default "
|
|
412
|
+
"branch could be determined (the main worktree "
|
|
413
|
+
"has no branch checked out and no local branch "
|
|
414
|
+
"is named main or master)")
|
|
415
|
+
|
|
416
|
+
merged = None
|
|
417
|
+
if default is not None:
|
|
418
|
+
rc, mout, _ = run_git(
|
|
419
|
+
repo_root, ["branch", "--merged", default,
|
|
420
|
+
"--format=%(refname:short)"])
|
|
421
|
+
if rc != 0:
|
|
422
|
+
# An unborn default branch (empty repo) has
|
|
423
|
+
# nothing merged into it by definition.
|
|
424
|
+
merged = set()
|
|
425
|
+
skipped.append(
|
|
426
|
+
f"merged-branch list: '{default}' has no "
|
|
427
|
+
f"commits yet, so no branch can be merged "
|
|
428
|
+
f"into it")
|
|
429
|
+
else:
|
|
430
|
+
merged = {ln.strip() for ln in mout.splitlines()
|
|
431
|
+
if ln.strip()}
|
|
432
|
+
|
|
433
|
+
checked_out = {}
|
|
434
|
+
for w in raw_wts:
|
|
435
|
+
if w["branch"]:
|
|
436
|
+
checked_out[w["branch"]] = w["path"]
|
|
437
|
+
|
|
438
|
+
# ---- branch records ------------------------------------
|
|
439
|
+
for b in branches:
|
|
440
|
+
info = _commit_info(repo_root, b["name"])
|
|
441
|
+
b["last_commit"] = info
|
|
442
|
+
b["age_days"] = _age(info, now)
|
|
443
|
+
b["checked_out_in"] = checked_out.get(b["name"])
|
|
444
|
+
b["is_default"] = (b["name"] == default)
|
|
445
|
+
b["merged"] = (b["name"] in merged
|
|
446
|
+
if merged is not None else None)
|
|
447
|
+
not_in_default = None
|
|
448
|
+
if default is not None:
|
|
449
|
+
if b["name"] == default:
|
|
450
|
+
not_in_default = 0
|
|
451
|
+
else:
|
|
452
|
+
not_in_default = _range_count(
|
|
453
|
+
repo_root, f"{default}..{b['name']}")
|
|
454
|
+
b["commits_not_in_default"] = not_in_default
|
|
455
|
+
if b["upstream"]:
|
|
456
|
+
n = _range_count(
|
|
457
|
+
repo_root, f"{b['upstream']}..{b['name']}")
|
|
458
|
+
if n is None:
|
|
459
|
+
b["unpushed_commits"] = None
|
|
460
|
+
b["unpushed_basis"] = (
|
|
461
|
+
f"upstream {b['upstream']} is configured "
|
|
462
|
+
f"but not present locally — count "
|
|
463
|
+
f"unavailable")
|
|
464
|
+
skipped.append(
|
|
465
|
+
f"unpushed count for branch "
|
|
466
|
+
f"{b['name']}: upstream "
|
|
467
|
+
f"{b['upstream']} not present locally")
|
|
468
|
+
else:
|
|
469
|
+
b["unpushed_commits"] = n
|
|
470
|
+
b["unpushed_basis"] = (
|
|
471
|
+
f"vs upstream {b['upstream']}")
|
|
472
|
+
elif b["is_default"]:
|
|
473
|
+
b["unpushed_commits"] = 0
|
|
474
|
+
b["unpushed_basis"] = "the default branch itself"
|
|
475
|
+
elif default is not None:
|
|
476
|
+
b["unpushed_commits"] = not_in_default
|
|
477
|
+
b["unpushed_basis"] = (
|
|
478
|
+
f"no upstream configured — counted against "
|
|
479
|
+
f"the default branch {default}")
|
|
480
|
+
else:
|
|
481
|
+
b["unpushed_commits"] = None
|
|
482
|
+
b["unpushed_basis"] = (
|
|
483
|
+
"no upstream configured and no default "
|
|
484
|
+
"branch determined — count unavailable")
|
|
485
|
+
b["stale"] = bool(
|
|
486
|
+
b["merged"] is True
|
|
487
|
+
and not b["is_default"]
|
|
488
|
+
and b["checked_out_in"] is None
|
|
489
|
+
and b["age_days"] is not None
|
|
490
|
+
and b["age_days"] >= stale_days)
|
|
491
|
+
|
|
492
|
+
# ---- worktree records + verdicts ------------------------
|
|
493
|
+
worktrees = []
|
|
494
|
+
for i, w in enumerate(raw_wts):
|
|
495
|
+
rec = dict(w)
|
|
496
|
+
rec["is_main"] = (i == 0)
|
|
497
|
+
rec["is_current"] = (
|
|
498
|
+
current_path is not None
|
|
499
|
+
and str(Path(w["path"]).resolve()) == current_path)
|
|
500
|
+
rec["status"] = None
|
|
501
|
+
rec["changed_files"] = None
|
|
502
|
+
rec["staged_files"] = None
|
|
503
|
+
rec["unstaged_files"] = None
|
|
504
|
+
rec["untracked_files"] = None
|
|
505
|
+
rec["upstream"] = None
|
|
506
|
+
rec["ahead"] = None
|
|
507
|
+
rec["behind"] = None
|
|
508
|
+
info = _commit_info(repo_root, w["head"])
|
|
509
|
+
rec["last_commit"] = info
|
|
510
|
+
rec["age_days"] = _age(info, now)
|
|
511
|
+
|
|
512
|
+
blockers = []
|
|
513
|
+
notes = []
|
|
514
|
+
if w["bare"]:
|
|
515
|
+
rec["status"] = "bare"
|
|
516
|
+
elif w["prunable"] or not Path(w["path"]).is_dir():
|
|
517
|
+
rec["status"] = "missing"
|
|
518
|
+
else:
|
|
519
|
+
rc, out, err = run_git(
|
|
520
|
+
w["path"],
|
|
521
|
+
["status", "--porcelain=v1", "--branch"])
|
|
522
|
+
if rc != 0:
|
|
523
|
+
rec["status"] = "error"
|
|
524
|
+
first = (err.strip().splitlines()
|
|
525
|
+
or ["unknown git error"])[0]
|
|
526
|
+
blockers.append(
|
|
527
|
+
f"working-tree status could not be read "
|
|
528
|
+
f"({first}) — its contents are unverified")
|
|
529
|
+
else:
|
|
530
|
+
rec["status"] = "ok"
|
|
531
|
+
st = parse_status(out)
|
|
532
|
+
rec["changed_files"] = st["changed"]
|
|
533
|
+
rec["staged_files"] = st["staged"]
|
|
534
|
+
rec["unstaged_files"] = st["unstaged"]
|
|
535
|
+
rec["untracked_files"] = st["untracked"]
|
|
536
|
+
rec["upstream"] = st["upstream"]
|
|
537
|
+
rec["ahead"] = st["ahead"]
|
|
538
|
+
rec["behind"] = st["behind"]
|
|
539
|
+
|
|
540
|
+
brec = by_name.get(w["branch"]) if w["branch"] \
|
|
541
|
+
else None
|
|
542
|
+
if rec["status"] == "ok":
|
|
543
|
+
if rec["changed_files"]:
|
|
544
|
+
blockers.append(
|
|
545
|
+
f"{rec['changed_files']} uncommitted "
|
|
546
|
+
f"change(s) (staged "
|
|
547
|
+
f"{rec['staged_files']}, unstaged "
|
|
548
|
+
f"{rec['unstaged_files']}) — they exist "
|
|
549
|
+
f"only in this worktree")
|
|
550
|
+
if rec["untracked_files"]:
|
|
551
|
+
blockers.append(
|
|
552
|
+
f"{rec['untracked_files']} untracked "
|
|
553
|
+
f"file(s) — they exist only in this "
|
|
554
|
+
f"worktree")
|
|
555
|
+
if w["detached"]:
|
|
556
|
+
blockers.append(
|
|
557
|
+
"detached HEAD — no branch holds this "
|
|
558
|
+
"worktree's position, so its merge and "
|
|
559
|
+
"unpushed state cannot be verified")
|
|
560
|
+
elif brec is not None:
|
|
561
|
+
if brec["merged"] is False:
|
|
562
|
+
blockers.append(
|
|
563
|
+
f"branch {w['branch']} is not merged "
|
|
564
|
+
f"into {default} "
|
|
565
|
+
f"({brec['commits_not_in_default']} "
|
|
566
|
+
f"commit(s) not in {default})")
|
|
567
|
+
elif brec["merged"] is None:
|
|
568
|
+
blockers.append(
|
|
569
|
+
f"merge status of branch "
|
|
570
|
+
f"{w['branch']} is unknown — no "
|
|
571
|
+
f"default branch could be determined")
|
|
572
|
+
if brec["unpushed_commits"]:
|
|
573
|
+
blockers.append(
|
|
574
|
+
f"{brec['unpushed_commits']} "
|
|
575
|
+
f"unpushed commit(s) "
|
|
576
|
+
f"({brec['unpushed_basis']})")
|
|
577
|
+
elif brec["unpushed_commits"] is None:
|
|
578
|
+
blockers.append(
|
|
579
|
+
f"unpushed state of branch "
|
|
580
|
+
f"{w['branch']} could not be "
|
|
581
|
+
f"verified ({brec['unpushed_basis']})")
|
|
582
|
+
|
|
583
|
+
reasons = []
|
|
584
|
+
if w["bare"]:
|
|
585
|
+
verdict = KEEP
|
|
586
|
+
reasons.append("bare repository — no working "
|
|
587
|
+
"tree to clean")
|
|
588
|
+
elif rec["is_main"]:
|
|
589
|
+
verdict = KEEP
|
|
590
|
+
reasons.append("the main worktree")
|
|
591
|
+
reasons.extend(f"note: {b}" for b in blockers)
|
|
592
|
+
elif w["locked"]:
|
|
593
|
+
verdict = KEEP
|
|
594
|
+
r = w["lock_reason"]
|
|
595
|
+
reasons.append(
|
|
596
|
+
f"locked by git"
|
|
597
|
+
+ (f" — reason recorded by git: {r}" if r
|
|
598
|
+
else " (no reason recorded)"))
|
|
599
|
+
reasons.extend(f"note: {b}" for b in blockers)
|
|
600
|
+
elif rec["status"] == "missing":
|
|
601
|
+
verdict = SAFE
|
|
602
|
+
pr = w["prunable_reason"]
|
|
603
|
+
reasons.append(
|
|
604
|
+
"the directory is gone and git marks this "
|
|
605
|
+
"worktree prunable"
|
|
606
|
+
+ (f" ({pr})" if pr else "")
|
|
607
|
+
+ " — clearing the registration cannot "
|
|
608
|
+
"lose files; its branch, if any, stays "
|
|
609
|
+
"in the branch list below")
|
|
610
|
+
elif blockers:
|
|
611
|
+
verdict = REVIEW
|
|
612
|
+
reasons.extend(blockers)
|
|
613
|
+
elif rec["is_current"]:
|
|
614
|
+
verdict = KEEP
|
|
615
|
+
reasons.append("the worktree this scan was run "
|
|
616
|
+
"from — a janitor does not sweep "
|
|
617
|
+
"the floor it stands on")
|
|
618
|
+
else:
|
|
619
|
+
verdict = SAFE
|
|
620
|
+
reasons.append(
|
|
621
|
+
f"clean, branch {w['branch']} merged into "
|
|
622
|
+
f"{default}, no unpushed commits, not "
|
|
623
|
+
f"locked")
|
|
624
|
+
if rec["is_current"] and not rec["is_main"]:
|
|
625
|
+
reasons.append("this is the worktree the scan "
|
|
626
|
+
"was run from")
|
|
627
|
+
rec["verdict"] = verdict
|
|
628
|
+
rec["reasons"] = reasons
|
|
629
|
+
worktrees.append(rec)
|
|
630
|
+
|
|
631
|
+
# A branch checked out ONLY in a registration whose
|
|
632
|
+
# directory is already gone becomes stale the moment
|
|
633
|
+
# the prune clears that registration — flag it now so
|
|
634
|
+
# the plan can sequence it after the prune.
|
|
635
|
+
missing_paths = {w["path"] for w in worktrees
|
|
636
|
+
if w["status"] == "missing"}
|
|
637
|
+
for b in branches:
|
|
638
|
+
b["stale_after_prune"] = bool(
|
|
639
|
+
b["merged"] is True
|
|
640
|
+
and not b["is_default"]
|
|
641
|
+
and not b["stale"]
|
|
642
|
+
and b["checked_out_in"] in missing_paths
|
|
643
|
+
and b["age_days"] is not None
|
|
644
|
+
and b["age_days"] >= stale_days)
|
|
645
|
+
|
|
646
|
+
# ---- stash (shared across all worktrees) ---------------
|
|
647
|
+
rc, sout, _ = run_git(repo_root, ["stash", "list"])
|
|
648
|
+
if rc != 0:
|
|
649
|
+
# git refuses to list the stash outside a work
|
|
650
|
+
# tree (a bare repository). Named, never silent.
|
|
651
|
+
skipped.append(
|
|
652
|
+
"stash list: git would not list the stash "
|
|
653
|
+
"here (bare repository — the command needs a "
|
|
654
|
+
"work tree)")
|
|
655
|
+
sout = ""
|
|
656
|
+
stash_entries = [ln for ln in sout.splitlines()
|
|
657
|
+
if ln.strip()]
|
|
658
|
+
|
|
659
|
+
stale = [b["name"] for b in branches if b["stale"]]
|
|
660
|
+
summary = {
|
|
661
|
+
"worktrees": len(worktrees),
|
|
662
|
+
"keep": sum(1 for w in worktrees
|
|
663
|
+
if w["verdict"] == KEEP),
|
|
664
|
+
"safe_to_remove": sum(1 for w in worktrees
|
|
665
|
+
if w["verdict"] == SAFE),
|
|
666
|
+
"review": sum(1 for w in worktrees
|
|
667
|
+
if w["verdict"] == REVIEW),
|
|
668
|
+
"branches": len(branches),
|
|
669
|
+
"stale_branches": len(stale),
|
|
670
|
+
"stashes": len(stash_entries),
|
|
671
|
+
}
|
|
672
|
+
summary["findings"] = (summary["safe_to_remove"]
|
|
673
|
+
+ summary["review"]
|
|
674
|
+
+ summary["stale_branches"])
|
|
675
|
+
return {"repo": str(repo_root),
|
|
676
|
+
"default_branch": default,
|
|
677
|
+
"default_branch_basis": basis,
|
|
678
|
+
"stale_days": stale_days,
|
|
679
|
+
"now_unix": now,
|
|
680
|
+
"worktrees": worktrees,
|
|
681
|
+
"branches": branches,
|
|
682
|
+
"stale_branches": stale,
|
|
683
|
+
"stale_after_prune": [b["name"] for b in branches
|
|
684
|
+
if b["stale_after_prune"]],
|
|
685
|
+
"stash": {"count": len(stash_entries),
|
|
686
|
+
"entries": stash_entries},
|
|
687
|
+
"checks_skipped": skipped,
|
|
688
|
+
"summary": summary,
|
|
689
|
+
"clean": summary["findings"] == 0}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# --------------------------------------------------------------------------
|
|
693
|
+
# The plan — printed commands, never executed
|
|
694
|
+
# --------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
def build_plan(result):
|
|
697
|
+
"""The exact commands that WOULD clean up the SAFE
|
|
698
|
+
items. Returned as data; printing is all that ever
|
|
699
|
+
happens to them."""
|
|
700
|
+
plan = []
|
|
701
|
+
need_prune = False
|
|
702
|
+
for w in result["worktrees"]:
|
|
703
|
+
if w["verdict"] != SAFE:
|
|
704
|
+
continue
|
|
705
|
+
if w["status"] == "missing":
|
|
706
|
+
need_prune = True
|
|
707
|
+
plan.append({
|
|
708
|
+
"kind": "worktree-prune",
|
|
709
|
+
"command": "git worktree prune",
|
|
710
|
+
"target": w["path"],
|
|
711
|
+
"reason": "registration whose directory is "
|
|
712
|
+
"already gone (one prune clears "
|
|
713
|
+
"every such registration)"})
|
|
714
|
+
else:
|
|
715
|
+
plan.append({
|
|
716
|
+
"kind": "worktree-remove",
|
|
717
|
+
"command": "git worktree remove "
|
|
718
|
+
+ shlex.quote(w["path"]),
|
|
719
|
+
"target": w["path"],
|
|
720
|
+
"reason": "; ".join(w["reasons"])})
|
|
721
|
+
if need_prune:
|
|
722
|
+
plan = [p for p in plan
|
|
723
|
+
if p["kind"] != "worktree-prune"] + [{
|
|
724
|
+
"kind": "worktree-prune",
|
|
725
|
+
"command": "git worktree prune",
|
|
726
|
+
"target": None,
|
|
727
|
+
"reason": "clears every prunable "
|
|
728
|
+
"worktree registration whose "
|
|
729
|
+
"directory is already gone"}]
|
|
730
|
+
for name in result["stale_branches"]:
|
|
731
|
+
plan.append({
|
|
732
|
+
"kind": "branch-delete",
|
|
733
|
+
"command": "git branch -d " + shlex.quote(name),
|
|
734
|
+
"target": name,
|
|
735
|
+
"reason": f"merged into "
|
|
736
|
+
f"{result['default_branch']}, last "
|
|
737
|
+
f"commit at least "
|
|
738
|
+
f"{result['stale_days']} day(s) old, "
|
|
739
|
+
f"not checked out anywhere — "
|
|
740
|
+
f"lowercase -d, so git itself refuses "
|
|
741
|
+
f"if the branch is somehow unmerged"})
|
|
742
|
+
for name in result.get("stale_after_prune", []):
|
|
743
|
+
plan.append({
|
|
744
|
+
"kind": "branch-delete-after-prune",
|
|
745
|
+
"command": "git branch -d " + shlex.quote(name),
|
|
746
|
+
"target": name,
|
|
747
|
+
"reason": f"merged into "
|
|
748
|
+
f"{result['default_branch']} and at "
|
|
749
|
+
f"least {result['stale_days']} day(s) "
|
|
750
|
+
f"old, but still checked out in a "
|
|
751
|
+
f"dead worktree registration — run "
|
|
752
|
+
f"this only AFTER the prune above has "
|
|
753
|
+
f"cleared that registration"})
|
|
754
|
+
return plan
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# --------------------------------------------------------------------------
|
|
758
|
+
# Text rendering
|
|
759
|
+
# --------------------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
def _commit_phrase(rec):
|
|
762
|
+
info = rec.get("last_commit")
|
|
763
|
+
if info is None:
|
|
764
|
+
return "no commits yet"
|
|
765
|
+
return (f"last commit {info['age_days']} day(s) ago, "
|
|
766
|
+
f"{info['date']} (UTC) — "
|
|
767
|
+
f"\"{info['subject']}\"")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _worktree_lines(w):
|
|
771
|
+
head = (w["head"] or "")[:7] or "—"
|
|
772
|
+
if w["bare"]:
|
|
773
|
+
top = f" [{w['verdict']}] {w['path']} — bare"
|
|
774
|
+
elif w["detached"]:
|
|
775
|
+
top = f" [{w['verdict']}] {w['path']} — detached @ {head}"
|
|
776
|
+
else:
|
|
777
|
+
top = (f" [{w['verdict']}] {w['path']} — branch "
|
|
778
|
+
f"{w['branch']} @ {head}")
|
|
779
|
+
lines = [top]
|
|
780
|
+
if w["status"] == "ok":
|
|
781
|
+
if w["changed_files"] or w["untracked_files"]:
|
|
782
|
+
state = (f"{w['changed_files']} uncommitted "
|
|
783
|
+
f"change(s), {w['untracked_files']} "
|
|
784
|
+
f"untracked file(s)")
|
|
785
|
+
else:
|
|
786
|
+
state = "clean"
|
|
787
|
+
extra = ""
|
|
788
|
+
if w["upstream"]:
|
|
789
|
+
extra = (f" · upstream {w['upstream']} "
|
|
790
|
+
f"(ahead {w['ahead']}, behind "
|
|
791
|
+
f"{w['behind']})")
|
|
792
|
+
lines.append(f" {state} · "
|
|
793
|
+
f"{_commit_phrase(w)}{extra}")
|
|
794
|
+
elif w["status"] == "missing":
|
|
795
|
+
lines.append(" directory gone (prunable)")
|
|
796
|
+
lines.append(f" why: {'; '.join(w['reasons'])}")
|
|
797
|
+
return lines
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def render_header(result, command):
|
|
801
|
+
repo = Path(result["repo"])
|
|
802
|
+
s = result["summary"]
|
|
803
|
+
dflt = result["default_branch"]
|
|
804
|
+
lines = [f"{TOOL} v{__version__} — {command} · repo "
|
|
805
|
+
f"{repo.name}"]
|
|
806
|
+
lines.append(
|
|
807
|
+
f"{result['repo']} · default branch "
|
|
808
|
+
f"{dflt if dflt else 'unknown'}"
|
|
809
|
+
f" ({result['default_branch_basis'] or '—'})"
|
|
810
|
+
f" · stale threshold "
|
|
811
|
+
f"{result['stale_days']} day(s)")
|
|
812
|
+
lines.append(
|
|
813
|
+
f"{s['worktrees']} worktree(s) — {s['keep']} KEEP"
|
|
814
|
+
f" · {s['safe_to_remove']} SAFE_TO_REMOVE"
|
|
815
|
+
f" · {s['review']} REVIEW · {s['branches']} local"
|
|
816
|
+
f" branch(es) · {s['stale_branches']} stale"
|
|
817
|
+
f" · {s['stashes']} stash entr"
|
|
818
|
+
f"{'y' if s['stashes'] == 1 else 'ies'}")
|
|
819
|
+
return lines
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def render_skipped(result):
|
|
823
|
+
if not result["checks_skipped"]:
|
|
824
|
+
return []
|
|
825
|
+
lines = ["", "CHECKS SKIPPED — named, never silent:"]
|
|
826
|
+
for c in result["checks_skipped"]:
|
|
827
|
+
lines.append(f" - {c}")
|
|
828
|
+
return lines
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def render_scan(result):
|
|
832
|
+
lines = render_header(result, "scan")
|
|
833
|
+
lines.append("")
|
|
834
|
+
lines.append("WORKTREES:")
|
|
835
|
+
for verdict in _VERDICT_ORDER:
|
|
836
|
+
for w in result["worktrees"]:
|
|
837
|
+
if w["verdict"] == verdict:
|
|
838
|
+
lines.extend(_worktree_lines(w))
|
|
839
|
+
lines.append("")
|
|
840
|
+
lines.append("BRANCHES:")
|
|
841
|
+
if not result["branches"]:
|
|
842
|
+
lines.append(" (no local branches — no commits yet)")
|
|
843
|
+
for b in result["branches"]:
|
|
844
|
+
bits = [b["name"]]
|
|
845
|
+
if b["is_default"]:
|
|
846
|
+
bits.append("default")
|
|
847
|
+
if b["checked_out_in"]:
|
|
848
|
+
bits.append(f"checked out at "
|
|
849
|
+
f"{b['checked_out_in']}")
|
|
850
|
+
if b["merged"] is True:
|
|
851
|
+
bits.append(f"merged into "
|
|
852
|
+
f"{result['default_branch']}")
|
|
853
|
+
elif b["merged"] is False:
|
|
854
|
+
bits.append(f"NOT merged into "
|
|
855
|
+
f"{result['default_branch']} "
|
|
856
|
+
f"({b['commits_not_in_default']} "
|
|
857
|
+
f"commit(s) not in it)")
|
|
858
|
+
else:
|
|
859
|
+
bits.append("merge status unknown")
|
|
860
|
+
if b["last_commit"] is not None:
|
|
861
|
+
bits.append(_commit_phrase(b))
|
|
862
|
+
if b["upstream"]:
|
|
863
|
+
bits.append(f"upstream {b['upstream']}, "
|
|
864
|
+
f"{b['unpushed_commits']} unpushed")
|
|
865
|
+
if b["stale"]:
|
|
866
|
+
bits.append("STALE")
|
|
867
|
+
elif b.get("stale_after_prune"):
|
|
868
|
+
bits.append("STALE once its dead worktree "
|
|
869
|
+
"registration is pruned")
|
|
870
|
+
lines.append(" " + " · ".join(bits))
|
|
871
|
+
if result["stash"]["count"]:
|
|
872
|
+
lines.append("")
|
|
873
|
+
lines.append(
|
|
874
|
+
f"STASH: {result['stash']['count']} entr"
|
|
875
|
+
f"{'y' if result['stash']['count'] == 1 else 'ies'}"
|
|
876
|
+
f" — the stash is shared across all worktrees;"
|
|
877
|
+
f" removing a worktree never touches it, and "
|
|
878
|
+
f"neither does this tool:")
|
|
879
|
+
for e in result["stash"]["entries"]:
|
|
880
|
+
lines.append(f" {e}")
|
|
881
|
+
lines.extend(render_skipped(result))
|
|
882
|
+
lines.append("")
|
|
883
|
+
if result["clean"]:
|
|
884
|
+
lines.append(
|
|
885
|
+
"CLEAN — every worktree is a keeper and no "
|
|
886
|
+
"branch is stale. Nothing awaits cleanup.")
|
|
887
|
+
else:
|
|
888
|
+
lines.append(
|
|
889
|
+
"VERDICT: cleanup candidates exist — run the "
|
|
890
|
+
"`plan` subcommand for the exact commands, "
|
|
891
|
+
"review the REVIEW items by hand first.")
|
|
892
|
+
lines.append(
|
|
893
|
+
"worktree-janitor only reads: every fact above "
|
|
894
|
+
"came from git's own read-only queries, nothing "
|
|
895
|
+
"was executed, removed or changed — not even an "
|
|
896
|
+
"index refresh (GIT_OPTIONAL_LOCKS=0). Verdicts "
|
|
897
|
+
"are git's facts (clean / merged / unpushed / "
|
|
898
|
+
"locked), never a guess; a check that could not "
|
|
899
|
+
"be made is named under CHECKS SKIPPED and its "
|
|
900
|
+
"worktree is REVIEW, never SAFE.")
|
|
901
|
+
return "\n".join(lines)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def render_plan(result):
|
|
905
|
+
plan = build_plan(result)
|
|
906
|
+
lines = render_header(result, "plan")
|
|
907
|
+
lines.append("")
|
|
908
|
+
if not plan:
|
|
909
|
+
lines.append(
|
|
910
|
+
"NOTHING TO DO — no worktree is SAFE_TO_REMOVE "
|
|
911
|
+
"and no branch is stale. (REVIEW items, if "
|
|
912
|
+
"any, are deliberately not in this plan: "
|
|
913
|
+
"something in them exists nowhere else.)")
|
|
914
|
+
else:
|
|
915
|
+
lines.append(
|
|
916
|
+
"CLEANUP PLAN — the exact commands, in order. "
|
|
917
|
+
"worktree-janitor does NOT run them; copy them "
|
|
918
|
+
"yourself after reading the REVIEW items:")
|
|
919
|
+
lines.append("")
|
|
920
|
+
for i, p in enumerate(plan, 1):
|
|
921
|
+
lines.append(f" {p['command']}")
|
|
922
|
+
lines.append(f" # {p['reason']}")
|
|
923
|
+
lines.append("")
|
|
924
|
+
lines.append(
|
|
925
|
+
"After running them, re-run "
|
|
926
|
+
"`python3 src/worktree_janitor.py scan` to "
|
|
927
|
+
"confirm the repo is CLEAN. Branch deletions "
|
|
928
|
+
"use lowercase -d: git itself refuses if a "
|
|
929
|
+
"branch is somehow unmerged. There is no -D "
|
|
930
|
+
"anywhere in this plan, deliberately.")
|
|
931
|
+
if result["summary"]["review"]:
|
|
932
|
+
lines.append("")
|
|
933
|
+
lines.append(
|
|
934
|
+
f"NOT IN THE PLAN: "
|
|
935
|
+
f"{result['summary']['review']} worktree(s) "
|
|
936
|
+
f"marked REVIEW — each holds something that "
|
|
937
|
+
f"exists nowhere else. See the scan report.")
|
|
938
|
+
lines.extend(render_skipped(result))
|
|
939
|
+
lines.append("")
|
|
940
|
+
lines.append(
|
|
941
|
+
"worktree-janitor only reads: this plan was "
|
|
942
|
+
"computed from git's own read-only queries and "
|
|
943
|
+
"printed as text. Nothing was executed, removed "
|
|
944
|
+
"or changed.")
|
|
945
|
+
return "\n".join(lines)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
# --------------------------------------------------------------------------
|
|
949
|
+
# CLI
|
|
950
|
+
# --------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
class _JsonAwareParser(argparse.ArgumentParser):
|
|
953
|
+
"""ArgumentParser that reports usage errors as JSON in
|
|
954
|
+
--json mode ({"error": ...} on stdout, same exit code
|
|
955
|
+
2) — an agent driving this tool with --json never gets
|
|
956
|
+
plain text or a traceback. The flag lives on the class
|
|
957
|
+
so subparsers share it."""
|
|
958
|
+
|
|
959
|
+
json_mode = False
|
|
960
|
+
|
|
961
|
+
def error(self, message):
|
|
962
|
+
if self.json_mode:
|
|
963
|
+
print(json.dumps({"error": message}))
|
|
964
|
+
self.exit(2)
|
|
965
|
+
super().error(message)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _add_common(ap):
|
|
969
|
+
ap.add_argument("dir", nargs="?", default=".",
|
|
970
|
+
help="any folder inside the repository "
|
|
971
|
+
"(default: current folder)")
|
|
972
|
+
ap.add_argument("--json", action="store_true",
|
|
973
|
+
help="print one JSON object instead of "
|
|
974
|
+
"text")
|
|
975
|
+
ap.add_argument("--stale-days", type=int,
|
|
976
|
+
default=DEFAULT_STALE_DAYS, metavar="N",
|
|
977
|
+
help="a merged branch whose last commit "
|
|
978
|
+
"is at least N days old counts "
|
|
979
|
+
"as stale (default: "
|
|
980
|
+
f"{DEFAULT_STALE_DAYS})")
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _require_dir(raw):
|
|
984
|
+
d = Path(raw)
|
|
985
|
+
if not d.exists():
|
|
986
|
+
raise ToolError(f"no such folder: {raw}")
|
|
987
|
+
if not d.is_dir():
|
|
988
|
+
raise ToolError(f"not a folder: {raw}")
|
|
989
|
+
return d
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _run(command, a):
|
|
993
|
+
if a.stale_days < 0:
|
|
994
|
+
raise ToolError("--stale-days must be 0 or more")
|
|
995
|
+
if git_exe() is None:
|
|
996
|
+
raise ToolError(
|
|
997
|
+
"git was not found on PATH — worktree-janitor "
|
|
998
|
+
"reads git's own records and cannot work "
|
|
999
|
+
"without it")
|
|
1000
|
+
d = _require_dir(a.dir)
|
|
1001
|
+
result = scan(d, a.stale_days)
|
|
1002
|
+
exit_code = 0 if result["clean"] else 1
|
|
1003
|
+
if command == "scan":
|
|
1004
|
+
if a.json:
|
|
1005
|
+
print(json.dumps(
|
|
1006
|
+
{"tool": TOOL, "version": __version__,
|
|
1007
|
+
"command": "scan", **result}, indent=2))
|
|
1008
|
+
else:
|
|
1009
|
+
print(render_scan(result))
|
|
1010
|
+
return exit_code
|
|
1011
|
+
plan = build_plan(result)
|
|
1012
|
+
if a.json:
|
|
1013
|
+
print(json.dumps(
|
|
1014
|
+
{"tool": TOOL, "version": __version__,
|
|
1015
|
+
"command": "plan", **result, "plan": plan},
|
|
1016
|
+
indent=2))
|
|
1017
|
+
else:
|
|
1018
|
+
print(render_plan(result))
|
|
1019
|
+
return exit_code
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def main(argv=None):
|
|
1023
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
1024
|
+
_JsonAwareParser.json_mode = "--json" in argv
|
|
1025
|
+
if argv and argv[0] not in ("scan", "plan", "-h",
|
|
1026
|
+
"--help"):
|
|
1027
|
+
argv = ["scan"] + argv
|
|
1028
|
+
if not argv:
|
|
1029
|
+
argv = ["scan"]
|
|
1030
|
+
ap = _JsonAwareParser(
|
|
1031
|
+
prog=TOOL,
|
|
1032
|
+
description="Read-only inventory of a git repo's "
|
|
1033
|
+
"worktrees and branches: dirty, "
|
|
1034
|
+
"unpushed, merged, locked, prunable, "
|
|
1035
|
+
"stale — with verdicts, and a cleanup "
|
|
1036
|
+
"plan it prints but never executes. "
|
|
1037
|
+
"(stdlib only, local only).")
|
|
1038
|
+
sub = ap.add_subparsers(dest="command",
|
|
1039
|
+
parser_class=_JsonAwareParser)
|
|
1040
|
+
p_scan = sub.add_parser(
|
|
1041
|
+
"scan", help="inventory + verdicts (default)")
|
|
1042
|
+
_add_common(p_scan)
|
|
1043
|
+
p_plan = sub.add_parser(
|
|
1044
|
+
"plan", help="print the exact cleanup commands "
|
|
1045
|
+
"for the SAFE items — never runs them")
|
|
1046
|
+
_add_common(p_plan)
|
|
1047
|
+
a = ap.parse_args(argv)
|
|
1048
|
+
command = a.command or "scan"
|
|
1049
|
+
if not hasattr(a, "dir"):
|
|
1050
|
+
# Bare `scan`/`plan` with no further args reaches
|
|
1051
|
+
# here only if argparse defaults were bypassed;
|
|
1052
|
+
# normalise defensively.
|
|
1053
|
+
a.dir, a.json, a.stale_days = ".", False, \
|
|
1054
|
+
DEFAULT_STALE_DAYS
|
|
1055
|
+
|
|
1056
|
+
def fail(msg):
|
|
1057
|
+
if getattr(a, "json", False):
|
|
1058
|
+
print(json.dumps({"error": msg}))
|
|
1059
|
+
else:
|
|
1060
|
+
print(f"ERROR: {msg}")
|
|
1061
|
+
return 2
|
|
1062
|
+
|
|
1063
|
+
try:
|
|
1064
|
+
return _run(command, a)
|
|
1065
|
+
except ToolError as exc:
|
|
1066
|
+
return fail(str(exc))
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
if __name__ == "__main__":
|
|
1070
|
+
sys.exit(main())
|