worktree-janitor 0.1.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/__init__.py +3 -0
- worktree_janitor/cli.py +152 -0
- worktree_janitor/core.py +333 -0
- worktree_janitor-0.1.0.dist-info/METADATA +110 -0
- worktree_janitor-0.1.0.dist-info/RECORD +9 -0
- worktree_janitor-0.1.0.dist-info/WHEEL +5 -0
- worktree_janitor-0.1.0.dist-info/entry_points.txt +2 -0
- worktree_janitor-0.1.0.dist-info/licenses/LICENSE +21 -0
- worktree_janitor-0.1.0.dist-info/top_level.txt +1 -0
worktree_janitor/cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""worktree-janitor CLI: list worktrees with claim status, prune dead ones."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .core import (
|
|
12
|
+
claim as core_claim,
|
|
13
|
+
claim_status,
|
|
14
|
+
heartbeat as core_heartbeat,
|
|
15
|
+
is_dirty,
|
|
16
|
+
is_merged,
|
|
17
|
+
list_worktrees,
|
|
18
|
+
prune as core_prune,
|
|
19
|
+
release as core_release,
|
|
20
|
+
repo_root,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _status_table(repo: str, stale_after: float, as_json: bool) -> int:
|
|
25
|
+
root = repo_root(repo)
|
|
26
|
+
rows = []
|
|
27
|
+
for wt in list_worktrees(root):
|
|
28
|
+
status = claim_status(wt, stale_after)
|
|
29
|
+
dirty = is_dirty(wt)
|
|
30
|
+
merged = is_merged(wt)
|
|
31
|
+
rows.append(
|
|
32
|
+
{
|
|
33
|
+
"path": wt.path,
|
|
34
|
+
"branch": wt.branch or "(detached)",
|
|
35
|
+
"head": wt.head[:8],
|
|
36
|
+
"claim": status,
|
|
37
|
+
"dirty": dirty,
|
|
38
|
+
"merged": merged,
|
|
39
|
+
"main": wt.is_main,
|
|
40
|
+
"locked": wt.locked,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
if as_json:
|
|
44
|
+
print(json.dumps(rows, indent=2))
|
|
45
|
+
return 0
|
|
46
|
+
header = f"{'PATH':<52} {'BRANCH':<18} {'CLAIM':<8} {'DIRTY':<6} {'MERGED':<7}"
|
|
47
|
+
print(header)
|
|
48
|
+
print("-" * len(header))
|
|
49
|
+
for r in rows:
|
|
50
|
+
merged = "?" if r["merged"] is None else ("yes" if r["merged"] else "no")
|
|
51
|
+
tags = []
|
|
52
|
+
if r["main"]:
|
|
53
|
+
tags.append("main")
|
|
54
|
+
if r["locked"]:
|
|
55
|
+
tags.append("locked")
|
|
56
|
+
path = r["path"] + (f" [{','.join(tags)}]" if tags else "")
|
|
57
|
+
print(
|
|
58
|
+
f"{path:<52} {r['branch']:<18} {r['claim']:<8} "
|
|
59
|
+
f"{'yes' if r['dirty'] else 'no':<6} {merged:<7}"
|
|
60
|
+
)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _prune(repo: str, dry_run: bool, yes: bool, include_unclaimed: bool,
|
|
65
|
+
stale_after: float) -> int:
|
|
66
|
+
effective_dry_run = dry_run or not yes
|
|
67
|
+
report = core_prune(
|
|
68
|
+
repo=repo,
|
|
69
|
+
dry_run=effective_dry_run,
|
|
70
|
+
include_unclaimed=include_unclaimed,
|
|
71
|
+
stale_after_minutes=stale_after,
|
|
72
|
+
)
|
|
73
|
+
if effective_dry_run:
|
|
74
|
+
print("DRY RUN -- nothing removed.")
|
|
75
|
+
for p in report["planned"]:
|
|
76
|
+
print(f"{'would remove' if effective_dry_run else 'removed'}: {p}")
|
|
77
|
+
for p in report["skipped_dirty"]:
|
|
78
|
+
print(f"SKIPPED (uncommitted changes, left alone): {p}", file=sys.stderr)
|
|
79
|
+
for p in report["skipped_active"]:
|
|
80
|
+
print(f"skipped (still claimed / unclaimed): {p}", file=sys.stderr)
|
|
81
|
+
for p in report["skipped_locked"]:
|
|
82
|
+
print(f"skipped (locked): {p}", file=sys.stderr)
|
|
83
|
+
for e in report["errors"]:
|
|
84
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
85
|
+
return 1 if report["errors"] else 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: list[str] | None = None) -> int:
|
|
89
|
+
ap = argparse.ArgumentParser(
|
|
90
|
+
prog="wtj",
|
|
91
|
+
description="Janitor for git worktrees left behind by dead agent sessions.",
|
|
92
|
+
)
|
|
93
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
94
|
+
ap.add_argument("--repo", default=".",
|
|
95
|
+
help="Path inside the repo to operate on (default: cwd).")
|
|
96
|
+
ap.add_argument("--stale-after", type=float, default=30.0,
|
|
97
|
+
help="Minutes after which a heartbeat-less claim is dead "
|
|
98
|
+
"(default: 30).")
|
|
99
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
100
|
+
|
|
101
|
+
p_list = sub.add_parser("list", help="List worktrees with claim status.")
|
|
102
|
+
p_list.add_argument("--json", action="store_true",
|
|
103
|
+
help="Machine-readable output.")
|
|
104
|
+
|
|
105
|
+
p_claim = sub.add_parser("claim", help="Claim a worktree for a session.")
|
|
106
|
+
p_claim.add_argument("path", help="Worktree path to claim.")
|
|
107
|
+
p_claim.add_argument("--session-id", default=None,
|
|
108
|
+
help="Session id (default: current PID as string).")
|
|
109
|
+
p_claim.add_argument("--note", default="", help="Free-text note.")
|
|
110
|
+
|
|
111
|
+
p_release = sub.add_parser("release", help="Release a worktree claim.")
|
|
112
|
+
p_release.add_argument("path", help="Worktree path.")
|
|
113
|
+
|
|
114
|
+
p_hb = sub.add_parser("heartbeat", help="Refresh a worktree claim heartbeat.")
|
|
115
|
+
p_hb.add_argument("path", help="Worktree path.")
|
|
116
|
+
|
|
117
|
+
p_prune = sub.add_parser("prune", help="Remove dead-claim worktrees.")
|
|
118
|
+
p_prune.add_argument("--dry-run", action="store_true",
|
|
119
|
+
help="Show what would be removed, remove nothing.")
|
|
120
|
+
p_prune.add_argument("--yes", action="store_true",
|
|
121
|
+
help="Actually remove. Without it, acts as a dry run.")
|
|
122
|
+
p_prune.add_argument("--include-unclaimed", action="store_true",
|
|
123
|
+
help="Also prune clean worktrees that have no claim.")
|
|
124
|
+
|
|
125
|
+
args = ap.parse_args(argv)
|
|
126
|
+
|
|
127
|
+
if args.cmd == "list":
|
|
128
|
+
return _status_table(args.repo, args.stale_after, args.json)
|
|
129
|
+
if args.cmd == "claim":
|
|
130
|
+
sid = args.session_id or f"pid:{os.getpid()}"
|
|
131
|
+
data = core_claim(args.path, sid, note=args.note)
|
|
132
|
+
print(f"claimed {os.path.realpath(args.path)} for session {data['session_id']}")
|
|
133
|
+
return 0
|
|
134
|
+
if args.cmd == "release":
|
|
135
|
+
if core_release(args.path):
|
|
136
|
+
print(f"released claim on {os.path.realpath(args.path)}")
|
|
137
|
+
else:
|
|
138
|
+
print("no claim found", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
return 0
|
|
141
|
+
if args.cmd == "heartbeat":
|
|
142
|
+
core_heartbeat(args.path)
|
|
143
|
+
print(f"heartbeat updated for {os.path.realpath(args.path)}")
|
|
144
|
+
return 0
|
|
145
|
+
if args.cmd == "prune":
|
|
146
|
+
return _prune(args.repo, args.dry_run, args.yes,
|
|
147
|
+
args.include_unclaimed, args.stale_after)
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
sys.exit(main())
|
worktree_janitor/core.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Core logic for worktree-janitor: listing, claim detection, and pruning.
|
|
2
|
+
|
|
3
|
+
A "claim" is a small JSON file written into a worktree's git admin dir
|
|
4
|
+
(the `gitdir` reported by `git worktree list --porcelain`) by the agent
|
|
5
|
+
session that created the worktree. It records the session id, the
|
|
6
|
+
owner PID, and a heartbeat timestamp.
|
|
7
|
+
|
|
8
|
+
A worktree is considered "dead" when its claim is stale:
|
|
9
|
+
- the owner PID no longer exists, or
|
|
10
|
+
- (when no PID is recorded) the heartbeat is older than the configured
|
|
11
|
+
staleness threshold.
|
|
12
|
+
|
|
13
|
+
Dead claims are how the janitor distinguishes a worktree its session
|
|
14
|
+
abandoned from one that is still in use. Worktrees with uncommitted
|
|
15
|
+
changes are NEVER pruned, whatever the claim state.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
CLAIM_FILENAME = "worktree-claim.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GitError(RuntimeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git(repo_path: str, *args: str, check: bool = True) -> str:
|
|
35
|
+
"""Run `git -C <repo_path> ...` and return stdout."""
|
|
36
|
+
proc = subprocess.run(
|
|
37
|
+
["git", "-C", repo_path, *args],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=60,
|
|
41
|
+
)
|
|
42
|
+
if check and proc.returncode != 0:
|
|
43
|
+
raise GitError(
|
|
44
|
+
f"git {' '.join(args)} failed in {repo_path}: "
|
|
45
|
+
f"{proc.stderr.strip() or proc.stdout.strip()}"
|
|
46
|
+
)
|
|
47
|
+
return proc.stdout
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def repo_root(path: str = ".") -> str:
|
|
51
|
+
"""Resolve the top-level directory of the git repo containing path."""
|
|
52
|
+
return os.path.realpath(
|
|
53
|
+
_git(path, "rev-parse", "--show-toplevel").strip()
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Worktree:
|
|
59
|
+
path: str
|
|
60
|
+
gitdir: str
|
|
61
|
+
head: str
|
|
62
|
+
branch: Optional[str] # None when detached
|
|
63
|
+
bare: bool
|
|
64
|
+
locked: bool
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_main(self) -> bool:
|
|
68
|
+
# The main worktree's admin dir is <repo>/.git itself.
|
|
69
|
+
return os.path.basename(os.path.dirname(self.gitdir)) != "worktrees"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_worktrees(repo: str = ".") -> list[Worktree]:
|
|
73
|
+
"""Parse `git worktree list --porcelain` into Worktree records."""
|
|
74
|
+
root = repo_root(repo)
|
|
75
|
+
out = _git(root, "worktree", "list", "--porcelain")
|
|
76
|
+
worktrees: list[Worktree] = []
|
|
77
|
+
cur: dict = {}
|
|
78
|
+
for line in out.splitlines():
|
|
79
|
+
if not line.strip():
|
|
80
|
+
if cur:
|
|
81
|
+
worktrees.append(_build_worktree(cur))
|
|
82
|
+
cur = {}
|
|
83
|
+
continue
|
|
84
|
+
key, _, value = line.partition(" ")
|
|
85
|
+
if key == "worktree":
|
|
86
|
+
cur["path"] = value
|
|
87
|
+
elif key == "gitdir":
|
|
88
|
+
cur["gitdir"] = value
|
|
89
|
+
elif key == "HEAD":
|
|
90
|
+
cur["head"] = value
|
|
91
|
+
elif key == "branch":
|
|
92
|
+
cur["branch"] = value
|
|
93
|
+
elif key == "detached":
|
|
94
|
+
cur["branch"] = None
|
|
95
|
+
elif key == "bare":
|
|
96
|
+
cur["bare"] = True
|
|
97
|
+
elif key == "locked":
|
|
98
|
+
cur["locked"] = True
|
|
99
|
+
if cur:
|
|
100
|
+
worktrees.append(_build_worktree(cur))
|
|
101
|
+
return worktrees
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _gitdir_of(path: str) -> str:
|
|
105
|
+
"""Absolute git admin dir for a worktree path."""
|
|
106
|
+
try:
|
|
107
|
+
return _git(path, "rev-parse", "--absolute-git-dir").strip()
|
|
108
|
+
except GitError:
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_worktree(cur: dict) -> Worktree:
|
|
113
|
+
path = os.path.realpath(cur.get("path", ""))
|
|
114
|
+
gitdir = cur.get("gitdir") or _gitdir_of(path)
|
|
115
|
+
return Worktree(
|
|
116
|
+
path=path,
|
|
117
|
+
gitdir=gitdir,
|
|
118
|
+
head=cur.get("head", ""),
|
|
119
|
+
branch=cur.get("branch"),
|
|
120
|
+
bare=bool(cur.get("bare", False)),
|
|
121
|
+
locked=bool(cur.get("locked", False)),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Claims
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def claim_path(wt: Worktree) -> str:
|
|
130
|
+
return os.path.join(wt.gitdir, CLAIM_FILENAME)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_claim(wt: Worktree) -> Optional[dict]:
|
|
134
|
+
"""Return the claim dict, or None if there is no claim file."""
|
|
135
|
+
path = claim_path(wt)
|
|
136
|
+
if not os.path.isfile(path):
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
140
|
+
data = json.load(fh)
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
return None
|
|
143
|
+
return data if isinstance(data, dict) else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def claim(
|
|
147
|
+
worktree_path: str,
|
|
148
|
+
session_id: str,
|
|
149
|
+
note: str = "",
|
|
150
|
+
pid: Optional[int] = None,
|
|
151
|
+
) -> dict:
|
|
152
|
+
"""Claim a worktree for a session. Returns the claim dict."""
|
|
153
|
+
root = repo_root(worktree_path)
|
|
154
|
+
wts = [w for w in list_worktrees(root) if w.path == os.path.realpath(worktree_path)]
|
|
155
|
+
if not wts:
|
|
156
|
+
raise GitError(f"not a worktree of {root}: {worktree_path}")
|
|
157
|
+
wt = wts[0]
|
|
158
|
+
now = time.time()
|
|
159
|
+
data = {
|
|
160
|
+
"session_id": session_id,
|
|
161
|
+
"pid": pid if pid is not None else os.getpid(),
|
|
162
|
+
"created": now,
|
|
163
|
+
"heartbeat": now,
|
|
164
|
+
"note": note,
|
|
165
|
+
}
|
|
166
|
+
os.makedirs(wt.gitdir, exist_ok=True)
|
|
167
|
+
with open(claim_path(wt), "w", encoding="utf-8") as fh:
|
|
168
|
+
json.dump(data, fh, indent=2)
|
|
169
|
+
return data
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def release(worktree_path: str) -> bool:
|
|
173
|
+
"""Remove the claim from a worktree. Returns True if one existed."""
|
|
174
|
+
root = repo_root(worktree_path)
|
|
175
|
+
wts = [w for w in list_worktrees(root) if w.path == os.path.realpath(worktree_path)]
|
|
176
|
+
if not wts:
|
|
177
|
+
raise GitError(f"not a worktree of {root}: {worktree_path}")
|
|
178
|
+
path = claim_path(wts[0])
|
|
179
|
+
if os.path.isfile(path):
|
|
180
|
+
os.remove(path)
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def heartbeat(worktree_path: str) -> dict:
|
|
186
|
+
"""Refresh the claim heartbeat. Raises if the worktree is unclaimed."""
|
|
187
|
+
root = repo_root(worktree_path)
|
|
188
|
+
wts = [w for w in list_worktrees(root) if w.path == os.path.realpath(worktree_path)]
|
|
189
|
+
if not wts:
|
|
190
|
+
raise GitError(f"not a worktree of {root}: {worktree_path}")
|
|
191
|
+
data = read_claim(wts[0])
|
|
192
|
+
if data is None:
|
|
193
|
+
raise GitError(f"worktree has no claim to heartbeat: {worktree_path}")
|
|
194
|
+
data["heartbeat"] = time.time()
|
|
195
|
+
with open(claim_path(wts[0]), "w", encoding="utf-8") as fh:
|
|
196
|
+
json.dump(data, fh, indent=2)
|
|
197
|
+
return data
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _pid_alive(pid: int) -> bool:
|
|
201
|
+
try:
|
|
202
|
+
os.kill(pid, 0)
|
|
203
|
+
except ProcessLookupError:
|
|
204
|
+
return False
|
|
205
|
+
except PermissionError:
|
|
206
|
+
return True
|
|
207
|
+
except OSError:
|
|
208
|
+
return False
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def claim_status(wt: Worktree, stale_after_minutes: float = 30.0) -> str:
|
|
213
|
+
"""Return 'active', 'dead', or 'none' for a worktree's claim."""
|
|
214
|
+
data = read_claim(wt)
|
|
215
|
+
if data is None:
|
|
216
|
+
return "none"
|
|
217
|
+
pid = data.get("pid")
|
|
218
|
+
if isinstance(pid, int) and pid > 0:
|
|
219
|
+
return "active" if _pid_alive(pid) else "dead"
|
|
220
|
+
heartbeat_ts = data.get("heartbeat", 0)
|
|
221
|
+
age = time.time() - float(heartbeat_ts or 0)
|
|
222
|
+
return "active" if age <= stale_after_minutes * 60 else "dead"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Worktree state
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def is_dirty(wt: Worktree) -> bool:
|
|
230
|
+
"""True when the worktree has uncommitted changes or untracked files."""
|
|
231
|
+
if not os.path.isdir(wt.path):
|
|
232
|
+
return False
|
|
233
|
+
try:
|
|
234
|
+
out = _git(wt.path, "status", "--porcelain", check=False)
|
|
235
|
+
except GitError:
|
|
236
|
+
return False
|
|
237
|
+
return bool(out.strip())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def is_merged(wt: Worktree, base_ref: Optional[str] = None) -> Optional[bool]:
|
|
241
|
+
"""True when the worktree HEAD is an ancestor of the base ref.
|
|
242
|
+
|
|
243
|
+
Returns None when the check cannot be performed.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
if base_ref is None:
|
|
247
|
+
# Default: the HEAD of the main worktree of this repo.
|
|
248
|
+
root = repo_root(wt.path)
|
|
249
|
+
wts = list_worktrees(root)
|
|
250
|
+
mains = [w for w in wts if w.is_main and not w.bare]
|
|
251
|
+
base_ref = mains[0].head if mains else "HEAD"
|
|
252
|
+
proc = subprocess.run(
|
|
253
|
+
["git", "-C", wt.path, "merge-base", "--is-ancestor", wt.head, base_ref],
|
|
254
|
+
capture_output=True,
|
|
255
|
+
timeout=60,
|
|
256
|
+
)
|
|
257
|
+
return proc.returncode == 0
|
|
258
|
+
except (GitError, OSError):
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Pruning
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@dataclass
|
|
267
|
+
class PrunePlan:
|
|
268
|
+
to_remove: list[Worktree]
|
|
269
|
+
skipped_dirty: list[Worktree]
|
|
270
|
+
skipped_active: list[Worktree]
|
|
271
|
+
skipped_locked: list[Worktree]
|
|
272
|
+
skipped_main: list[Worktree]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def plan_prune(
|
|
276
|
+
repo: str = ".",
|
|
277
|
+
include_unclaimed: bool = False,
|
|
278
|
+
stale_after_minutes: float = 30.0,
|
|
279
|
+
) -> PrunePlan:
|
|
280
|
+
"""Decide what a prune run would remove, without removing anything."""
|
|
281
|
+
root = repo_root(repo)
|
|
282
|
+
plan = PrunePlan([], [], [], [], [])
|
|
283
|
+
for wt in list_worktrees(root):
|
|
284
|
+
if wt.is_main or wt.bare:
|
|
285
|
+
plan.skipped_main.append(wt)
|
|
286
|
+
continue
|
|
287
|
+
if wt.locked:
|
|
288
|
+
plan.skipped_locked.append(wt)
|
|
289
|
+
continue
|
|
290
|
+
status = claim_status(wt, stale_after_minutes)
|
|
291
|
+
if status == "active":
|
|
292
|
+
plan.skipped_active.append(wt)
|
|
293
|
+
continue
|
|
294
|
+
if status == "none" and not include_unclaimed:
|
|
295
|
+
plan.skipped_active.append(wt) # unclaimed, left alone by default
|
|
296
|
+
continue
|
|
297
|
+
if is_dirty(wt):
|
|
298
|
+
plan.skipped_dirty.append(wt)
|
|
299
|
+
continue
|
|
300
|
+
plan.to_remove.append(wt)
|
|
301
|
+
return plan
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def prune(
|
|
305
|
+
repo: str = ".",
|
|
306
|
+
dry_run: bool = True,
|
|
307
|
+
include_unclaimed: bool = False,
|
|
308
|
+
stale_after_minutes: float = 30.0,
|
|
309
|
+
) -> dict:
|
|
310
|
+
"""Remove dead-claim worktrees. Returns a report dict.
|
|
311
|
+
|
|
312
|
+
Actual removal only happens when dry_run is False. Worktrees with
|
|
313
|
+
uncommitted changes are never removed.
|
|
314
|
+
"""
|
|
315
|
+
plan = plan_prune(repo, include_unclaimed, stale_after_minutes)
|
|
316
|
+
removed: list[str] = []
|
|
317
|
+
errors: list[str] = []
|
|
318
|
+
if not dry_run:
|
|
319
|
+
for wt in plan.to_remove:
|
|
320
|
+
try:
|
|
321
|
+
_git(repo, "worktree", "remove", wt.path)
|
|
322
|
+
removed.append(wt.path)
|
|
323
|
+
except GitError as exc:
|
|
324
|
+
errors.append(f"{wt.path}: {exc}")
|
|
325
|
+
return {
|
|
326
|
+
"dry_run": dry_run,
|
|
327
|
+
"removed": removed,
|
|
328
|
+
"errors": errors,
|
|
329
|
+
"planned": [w.path for w in plan.to_remove],
|
|
330
|
+
"skipped_dirty": [w.path for w in plan.skipped_dirty],
|
|
331
|
+
"skipped_active": [w.path for w in plan.skipped_active],
|
|
332
|
+
"skipped_locked": [w.path for w in plan.skipped_locked],
|
|
333
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: worktree-janitor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clean up git worktrees left behind by dead agent sessions
|
|
5
|
+
Author: hao li
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/worktree-janitor
|
|
8
|
+
Project-URL: Repository, https://github.com/hahahahahahahahah6/worktree-janitor
|
|
9
|
+
Project-URL: Issues, https://github.com/hahahahahahahahah6/worktree-janitor/issues
|
|
10
|
+
Keywords: git,worktree,agents,cleanup,cli
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# worktree-janitor
|
|
22
|
+
|
|
23
|
+
Dead-session worktree cleanup for git. Multi-session agent fleets create
|
|
24
|
+
`git worktree`s all the time -- and when a session dies, its worktrees leak:
|
|
25
|
+
stale checkouts pile up, branches linger, and nobody remembers which
|
|
26
|
+
worktree belongs to a live session. `worktree-janitor` is the CLI that
|
|
27
|
+
finds them and prunes them, safely.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install worktree-janitor
|
|
31
|
+
|
|
32
|
+
wtj list # worktrees + claim status
|
|
33
|
+
wtj claim ./my-wt --session-id s1 # claim a worktree for your session
|
|
34
|
+
wtj heartbeat ./my-wt # refresh the claim while you work
|
|
35
|
+
wtj prune --dry-run # see what would go
|
|
36
|
+
wtj prune --yes # remove dead-claim worktrees
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
A **claim** is a small JSON file (`worktree-claim.json`) stored in the
|
|
42
|
+
worktree's git admin dir (the `gitdir` reported by
|
|
43
|
+
`git worktree list --porcelain`), written by the session that created the
|
|
44
|
+
worktree. It records the session id, the owner PID, and a heartbeat
|
|
45
|
+
timestamp.
|
|
46
|
+
|
|
47
|
+
A claim is **dead** when:
|
|
48
|
+
- the owner PID no longer exists, or
|
|
49
|
+
- (no PID recorded) the heartbeat is older than `--stale-after` minutes
|
|
50
|
+
(default 30).
|
|
51
|
+
|
|
52
|
+
`wtj prune` removes worktrees whose claim is dead, and leaves everything
|
|
53
|
+
else alone. The hard rule: **a worktree with uncommitted changes is never
|
|
54
|
+
pruned**, no matter what. It is reported as skipped with a warning. The
|
|
55
|
+
main worktree and locked worktrees are never touched either. Clean,
|
|
56
|
+
unclaimed worktrees are skipped by default too; pass
|
|
57
|
+
`--include-unclaimed` to prune those as well.
|
|
58
|
+
|
|
59
|
+
`prune` is report-only unless you pass `--yes` (or use `--dry-run` to
|
|
60
|
+
preview).
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
wtj list [--json] [--stale-after MIN]
|
|
66
|
+
wtj claim <path> [--session-id ID] [--note TEXT]
|
|
67
|
+
wtj heartbeat <path>
|
|
68
|
+
wtj release <path>
|
|
69
|
+
wtj prune [--dry-run] [--yes] [--include-unclaimed] [--stale-after MIN]
|
|
70
|
+
wtj --repo /path/to/repo <command> # operate on any repo
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Differentiation
|
|
74
|
+
|
|
75
|
+
There are two sane shapes for worktree hygiene:
|
|
76
|
+
|
|
77
|
+
- **Claim guards (interception style)**, e.g. Ben Newton's worktree claim
|
|
78
|
+
guard Mod for session-fleet: they intercept at *creation time* and stop a
|
|
79
|
+
session from touching a worktree another live session owns. Prevention.
|
|
80
|
+
- **worktree-janitor (cleanup style)**: it runs *after the fact*. It does
|
|
81
|
+
not hook into your session runner or block anything -- it scans,
|
|
82
|
+
classifies, and one-shot prunes what is verifiably dead. Janitor, not
|
|
83
|
+
bouncer.
|
|
84
|
+
|
|
85
|
+
The two compose: guard at creation, janitor on a cron or after a session
|
|
86
|
+
fleet shuts down. This project is a sibling of `routine-sweeper` -- the
|
|
87
|
+
same "session hygiene" family, but for git worktrees instead of routine
|
|
88
|
+
files.
|
|
89
|
+
|
|
90
|
+
## Safety model
|
|
91
|
+
|
|
92
|
+
- dirty worktrees (uncommitted changes or untracked files) are never pruned
|
|
93
|
+
- the main worktree and `bare` repos are never pruned
|
|
94
|
+
- locked worktrees are skipped
|
|
95
|
+
- active claims are skipped
|
|
96
|
+
- unclaimed worktrees are skipped unless `--include-unclaimed`
|
|
97
|
+
- `--yes` is required for any deletion; default is report-only
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python -m pytest tests/ -q # 12 smoke tests, stdlib only
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
No dependencies -- the package is stdlib-only on purpose (it should work
|
|
106
|
+
in any agent sandbox without a network install).
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
worktree_janitor/__init__.py,sha256=-H96OgBjZpJwey5bQJd7l2gbjNEw8JD8CDTn-nEUbSM,102
|
|
2
|
+
worktree_janitor/cli.py,sha256=iTKSbaTuQ81qstcn9u9TiFchu1aSEbCOUf4uRCG3HCI,5573
|
|
3
|
+
worktree_janitor/core.py,sha256=5hyplmWu9MBMnnCRsTWmbXYwnF3n-l2tfFMs_uE3hYA,10400
|
|
4
|
+
worktree_janitor-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
|
|
5
|
+
worktree_janitor-0.1.0.dist-info/METADATA,sha256=g3cLNRFPQ39gbqAFOqXkOppw7kO6FFK8uC0_16odQ1o,4021
|
|
6
|
+
worktree_janitor-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
worktree_janitor-0.1.0.dist-info/entry_points.txt,sha256=HYQwQzi48NtvXuc5koDKTDmLt9GkgZHh30uAQwlz4T0,50
|
|
8
|
+
worktree_janitor-0.1.0.dist-info/top_level.txt,sha256=jNq5573KmT1sQyEQodW2gCPv7mq7BsHHjQPNPiusCqo,17
|
|
9
|
+
worktree_janitor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
worktree_janitor
|