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 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())