worktree-janitor 0.1.0__tar.gz

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.
@@ -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,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,90 @@
1
+ # worktree-janitor
2
+
3
+ Dead-session worktree cleanup for git. Multi-session agent fleets create
4
+ `git worktree`s all the time -- and when a session dies, its worktrees leak:
5
+ stale checkouts pile up, branches linger, and nobody remembers which
6
+ worktree belongs to a live session. `worktree-janitor` is the CLI that
7
+ finds them and prunes them, safely.
8
+
9
+ ```bash
10
+ pip install worktree-janitor
11
+
12
+ wtj list # worktrees + claim status
13
+ wtj claim ./my-wt --session-id s1 # claim a worktree for your session
14
+ wtj heartbeat ./my-wt # refresh the claim while you work
15
+ wtj prune --dry-run # see what would go
16
+ wtj prune --yes # remove dead-claim worktrees
17
+ ```
18
+
19
+ ## How it works
20
+
21
+ A **claim** is a small JSON file (`worktree-claim.json`) stored in the
22
+ worktree's git admin dir (the `gitdir` reported by
23
+ `git worktree list --porcelain`), written by the session that created the
24
+ worktree. It records the session id, the owner PID, and a heartbeat
25
+ timestamp.
26
+
27
+ A claim is **dead** when:
28
+ - the owner PID no longer exists, or
29
+ - (no PID recorded) the heartbeat is older than `--stale-after` minutes
30
+ (default 30).
31
+
32
+ `wtj prune` removes worktrees whose claim is dead, and leaves everything
33
+ else alone. The hard rule: **a worktree with uncommitted changes is never
34
+ pruned**, no matter what. It is reported as skipped with a warning. The
35
+ main worktree and locked worktrees are never touched either. Clean,
36
+ unclaimed worktrees are skipped by default too; pass
37
+ `--include-unclaimed` to prune those as well.
38
+
39
+ `prune` is report-only unless you pass `--yes` (or use `--dry-run` to
40
+ preview).
41
+
42
+ ## Usage
43
+
44
+ ```
45
+ wtj list [--json] [--stale-after MIN]
46
+ wtj claim <path> [--session-id ID] [--note TEXT]
47
+ wtj heartbeat <path>
48
+ wtj release <path>
49
+ wtj prune [--dry-run] [--yes] [--include-unclaimed] [--stale-after MIN]
50
+ wtj --repo /path/to/repo <command> # operate on any repo
51
+ ```
52
+
53
+ ## Differentiation
54
+
55
+ There are two sane shapes for worktree hygiene:
56
+
57
+ - **Claim guards (interception style)**, e.g. Ben Newton's worktree claim
58
+ guard Mod for session-fleet: they intercept at *creation time* and stop a
59
+ session from touching a worktree another live session owns. Prevention.
60
+ - **worktree-janitor (cleanup style)**: it runs *after the fact*. It does
61
+ not hook into your session runner or block anything -- it scans,
62
+ classifies, and one-shot prunes what is verifiably dead. Janitor, not
63
+ bouncer.
64
+
65
+ The two compose: guard at creation, janitor on a cron or after a session
66
+ fleet shuts down. This project is a sibling of `routine-sweeper` -- the
67
+ same "session hygiene" family, but for git worktrees instead of routine
68
+ files.
69
+
70
+ ## Safety model
71
+
72
+ - dirty worktrees (uncommitted changes or untracked files) are never pruned
73
+ - the main worktree and `bare` repos are never pruned
74
+ - locked worktrees are skipped
75
+ - active claims are skipped
76
+ - unclaimed worktrees are skipped unless `--include-unclaimed`
77
+ - `--yes` is required for any deletion; default is report-only
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ python -m pytest tests/ -q # 12 smoke tests, stdlib only
83
+ ```
84
+
85
+ No dependencies -- the package is stdlib-only on purpose (it should work
86
+ in any agent sandbox without a network install).
87
+
88
+ ## License
89
+
90
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "worktree-janitor"
7
+ version = "0.1.0"
8
+ description = "Clean up git worktrees left behind by dead agent sessions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "hao li" }]
13
+ keywords = ["git", "worktree", "agents", "cleanup", "cli"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Environment :: Console",
19
+ "Topic :: Software Development :: Version Control :: Git",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/hahahahahahahahah6/worktree-janitor"
25
+ Repository = "https://github.com/hahahahahahahahah6/worktree-janitor"
26
+ Issues = "https://github.com/hahahahahahahahah6/worktree-janitor/issues"
27
+
28
+ [project.scripts]
29
+ wtj = "worktree_janitor.cli:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """worktree-janitor: clean up worktrees left behind by dead agent sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())
@@ -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,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/worktree_janitor/__init__.py
5
+ src/worktree_janitor/cli.py
6
+ src/worktree_janitor/core.py
7
+ src/worktree_janitor.egg-info/PKG-INFO
8
+ src/worktree_janitor.egg-info/SOURCES.txt
9
+ src/worktree_janitor.egg-info/dependency_links.txt
10
+ src/worktree_janitor.egg-info/entry_points.txt
11
+ src/worktree_janitor.egg-info/top_level.txt
12
+ tests/test_janitor.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wtj = worktree_janitor.cli:main
@@ -0,0 +1 @@
1
+ worktree_janitor
@@ -0,0 +1,179 @@
1
+ """Smoke tests for worktree-janitor. All fixtures use real temporary git repos."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import time
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
12
+
13
+ from worktree_janitor.core import ( # noqa: E402
14
+ CLAIM_FILENAME,
15
+ claim as core_claim,
16
+ claim_status,
17
+ heartbeat as core_heartbeat,
18
+ is_dirty,
19
+ list_worktrees,
20
+ prune,
21
+ release as core_release,
22
+ )
23
+
24
+
25
+ def _git(cwd, *args):
26
+ subprocess.run(["git", "-C", cwd, *args], check=True,
27
+ capture_output=True, text=True)
28
+
29
+
30
+ def make_repo(tmp_path):
31
+ repo = tmp_path / "repo"
32
+ repo.mkdir()
33
+ _git(str(repo), "init", "-b", "main")
34
+ _git(str(repo), "config", "user.email", "test@example.com")
35
+ _git(str(repo), "config", "user.name", "Test")
36
+ (repo / "README.md").write_text("hello\n")
37
+ _git(str(repo), "add", ".")
38
+ _git(str(repo), "commit", "-m", "init")
39
+ return str(repo)
40
+
41
+
42
+ def add_worktree(repo, name, branch):
43
+ wt_path = os.path.join(os.path.dirname(repo), name)
44
+ _git(repo, "worktree", "add", wt_path, "-b", branch)
45
+ return wt_path
46
+
47
+
48
+ def find_wt(repo, path):
49
+ path = os.path.realpath(path)
50
+ for wt in list_worktrees(repo):
51
+ if wt.path == path:
52
+ return wt
53
+ raise AssertionError(f"worktree not found: {path}")
54
+
55
+
56
+ def dead_pid():
57
+ # A PID that cannot exist on a normal machine.
58
+ return 999_999_999
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Claim detection
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def test_claim_active_with_live_pid(tmp_path):
66
+ repo = make_repo(tmp_path)
67
+ wt_path = add_worktree(repo, "wt1", "feat-1")
68
+ core_claim(wt_path, "sess-live", pid=os.getpid())
69
+ assert claim_status(find_wt(repo, wt_path)) == "active"
70
+
71
+
72
+ def test_claim_dead_with_dead_pid(tmp_path):
73
+ repo = make_repo(tmp_path)
74
+ wt_path = add_worktree(repo, "wt2", "feat-2")
75
+ core_claim(wt_path, "sess-dead", pid=dead_pid())
76
+ assert claim_status(find_wt(repo, wt_path)) == "dead"
77
+
78
+
79
+ def test_claim_dead_with_stale_heartbeat(tmp_path):
80
+ repo = make_repo(tmp_path)
81
+ wt_path = add_worktree(repo, "wt3", "feat-3")
82
+ core_claim(wt_path, "sess-stale", pid=dead_pid())
83
+ # Rewrite the claim with no PID and an ancient heartbeat.
84
+ wt = find_wt(repo, wt_path)
85
+ claim_file = os.path.join(wt.gitdir, CLAIM_FILENAME)
86
+ with open(claim_file, "w") as fh:
87
+ json.dump({"session_id": "sess-stale", "pid": None,
88
+ "created": 0, "heartbeat": 1, "note": ""}, fh)
89
+ assert claim_status(find_wt(repo, wt_path), stale_after_minutes=30) == "dead"
90
+
91
+
92
+ def test_claim_active_with_fresh_heartbeat(tmp_path):
93
+ repo = make_repo(tmp_path)
94
+ wt_path = add_worktree(repo, "wt4", "feat-4")
95
+ core_claim(wt_path, "sess-fresh", pid=dead_pid())
96
+ wt = find_wt(repo, wt_path)
97
+ claim_file = os.path.join(wt.gitdir, CLAIM_FILENAME)
98
+ with open(claim_file, "w") as fh:
99
+ json.dump({"session_id": "sess-fresh", "pid": None,
100
+ "created": time.time(), "heartbeat": time.time(),
101
+ "note": ""}, fh)
102
+ assert claim_status(find_wt(repo, wt_path), stale_after_minutes=30) == "active"
103
+
104
+
105
+ def test_claim_none_when_no_claim_file(tmp_path):
106
+ repo = make_repo(tmp_path)
107
+ wt_path = add_worktree(repo, "wt5", "feat-5")
108
+ assert claim_status(find_wt(repo, wt_path)) == "none"
109
+
110
+
111
+ def test_heartbeat_refreshes_claim(tmp_path):
112
+ repo = make_repo(tmp_path)
113
+ wt_path = add_worktree(repo, "wt6", "feat-6")
114
+ core_claim(wt_path, "sess-hb", pid=os.getpid())
115
+ before = core_heartbeat(wt_path)["heartbeat"]
116
+ assert before <= time.time()
117
+ assert claim_status(find_wt(repo, wt_path)) == "active"
118
+
119
+
120
+ def test_release_removes_claim(tmp_path):
121
+ repo = make_repo(tmp_path)
122
+ wt_path = add_worktree(repo, "wt7", "feat-7")
123
+ core_claim(wt_path, "sess-rel", pid=os.getpid())
124
+ assert core_release(wt_path) is True
125
+ assert claim_status(find_wt(repo, wt_path)) == "none"
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Pruning
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def test_prune_removes_dead_clean_worktree(tmp_path):
133
+ repo = make_repo(tmp_path)
134
+ wt_path = add_worktree(repo, "wt8", "feat-8")
135
+ core_claim(wt_path, "sess-dead", pid=dead_pid())
136
+ report = prune(repo=repo, dry_run=False)
137
+ assert wt_path in report["removed"]
138
+ assert not os.path.isdir(wt_path)
139
+
140
+
141
+ def test_prune_skips_dirty_worktree_with_warning(tmp_path):
142
+ repo = make_repo(tmp_path)
143
+ wt_path = add_worktree(repo, "wt9", "feat-9")
144
+ core_claim(wt_path, "sess-dead", pid=dead_pid())
145
+ with open(os.path.join(wt_path, "uncommitted.txt"), "w") as fh:
146
+ fh.write("do not delete me\n")
147
+ assert is_dirty(find_wt(repo, wt_path))
148
+ report = prune(repo=repo, dry_run=False)
149
+ assert wt_path in report["skipped_dirty"]
150
+ assert os.path.isdir(wt_path), "dirty worktree must never be pruned"
151
+
152
+
153
+ def test_dry_run_deletes_nothing(tmp_path):
154
+ repo = make_repo(tmp_path)
155
+ wt_path = add_worktree(repo, "wt10", "feat-10")
156
+ core_claim(wt_path, "sess-dead", pid=dead_pid())
157
+ report = prune(repo=repo, dry_run=True)
158
+ assert wt_path in report["planned"]
159
+ assert report["removed"] == []
160
+ assert os.path.isdir(wt_path)
161
+
162
+
163
+ def test_prune_skips_unclaimed_by_default(tmp_path):
164
+ repo = make_repo(tmp_path)
165
+ wt_path = add_worktree(repo, "wt11", "feat-11")
166
+ report = prune(repo=repo, dry_run=False, include_unclaimed=False)
167
+ assert os.path.isdir(wt_path)
168
+ assert wt_path not in report["removed"]
169
+ report2 = prune(repo=repo, dry_run=False, include_unclaimed=True)
170
+ assert wt_path in report2["removed"]
171
+
172
+
173
+ def test_prune_never_touches_main_worktree(tmp_path):
174
+ repo = make_repo(tmp_path)
175
+ # Even an unclaimed, clean setup must not remove the main checkout.
176
+ report = prune(repo=repo, dry_run=False, include_unclaimed=True)
177
+ assert os.path.isdir(repo)
178
+ mains = [w for w in list_worktrees(repo) if w.is_main]
179
+ assert len(mains) == 1