git-wtclean 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.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: git-wtclean
3
+ Version: 0.1.0
4
+ Summary: Clean up the git worktrees coding agents leave behind, without losing work
5
+ Project-URL: Homepage, https://github.com/Ar9av/wtclean
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ai-agents,claude-code,cleanup,codex,git,worktree
9
+ Classifier: Environment :: Console
10
+ Classifier: Operating System :: MacOS
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Version Control :: Git
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # wtclean
18
+
19
+ Clean up the git worktrees your coding agents leave behind, without losing work.
20
+
21
+ ![wtclean demo](https://raw.githubusercontent.com/Ar9av/wtclean/main/screenshot.png)
22
+
23
+ ## The problem
24
+
25
+ Claude Code, Codex, Cursor, Conductor and friends run each task in its own **git worktree**: a second checkout of your repo in another folder, on its own branch. That's what lets five agents work on one repo without stepping on each other.
26
+
27
+ Nobody deletes them afterwards. The agent finishes, the PR merges, and the folder stays, with its own copy of `node_modules`, build output and `.venv`. On one machine after a few weeks:
28
+
29
+ - **287 worktrees across 101 repos**, two repos with 140+ each
30
+ - **54 GB** of disk, mostly duplicated dependencies
31
+ - `git worktree list` too long to read, branch pickers full of dead branches
32
+ - no way to tell which ones an agent is still using
33
+
34
+ The obvious fixes don't work:
35
+
36
+ | You try | What happens |
37
+ |---|---|
38
+ | `git worktree prune` | Only forgets worktrees whose folder is *already* deleted. Frees nothing. |
39
+ | `rm -rf` the folders | Deletes uncommitted work, and any `.env` you copied in. Git's metadata is left dangling. |
40
+ | `git worktree remove --force` | Same: `--force` is the flag that skips the "you have changes" check. |
41
+ | Ask the agent to clean up | [It happens](https://mergify.com/blog/the-day-my-ai-agent-deleted-29-git-worktrees): an agent removed 29 active worktrees. |
42
+
43
+ ## What wtclean does
44
+
45
+ It looks at every worktree and sorts it into one of three groups:
46
+
47
+ - **REMOVE** if it's clean and either
48
+ - its branch is merged or the remote branch was deleted, and it's been idle for 24 hours, or
49
+ - it's unmerged and has been idle for 7 days. The folder is removed and the branch is kept.
50
+ - **PRUNE** if the folder is already gone and git still lists it.
51
+ - **KEEP** if it has uncommitted changes, is locked, has a process running inside it, was used recently, holds a `.env` the main checkout doesn't have, or is a detached checkout with unpushed commits.
52
+
53
+ It's a dry run until you add `--apply`.
54
+
55
+ ### Why nothing gets lost
56
+
57
+ - Removal uses plain `git worktree remove`. Git itself refuses to delete a worktree with changes.
58
+ - A branch is only deleted with `git branch -d`, which refuses unmerged work. Removing the folder of an unmerged branch keeps every commit, and `git worktree add <path> <branch>` brings it back.
59
+ - What does get deleted is ignored stuff: `node_modules`, build output, caches. That's the disk you get back. Ignored `.env` files are checked first, and a worktree is kept if its `.env` differs from the main checkout's.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pipx install git-wtclean # or: uv tool install git-wtclean / pip install git-wtclean
65
+ ```
66
+
67
+ That gives you `wtclean`, and `git wtclean` works too. Needs git and Python 3.9+, macOS or Linux.
68
+
69
+ No pip? It's one file:
70
+
71
+ ```bash
72
+ mkdir -p ~/.local/bin
73
+ curl -fsSL https://raw.githubusercontent.com/Ar9av/wtclean/main/git_wtclean.py -o ~/.local/bin/wtclean
74
+ chmod +x ~/.local/bin/wtclean
75
+ ```
76
+
77
+ If `wtclean` isn't found afterwards, add `export PATH="$HOME/.local/bin:$PATH"` to your `~/.zshrc` or `~/.bashrc`.
78
+
79
+ ## Use
80
+
81
+ ```bash
82
+ wtclean # what would go, in the repo you're in
83
+ wtclean --scan ~/code # same, for every repo under ~/code
84
+ wtclean --scan ~/code --fetch --apply # refresh from remotes, then clean up
85
+ ```
86
+
87
+ Want to see it before pointing it at real repos? Clone this repo and run `./demo.sh`. It builds a throwaway repo full of fake agent worktrees and runs wtclean on it.
88
+
89
+ The first `--apply` on a big backlog can take several minutes, mostly deleting `node_modules`. It shows progress as it goes.
90
+
91
+ ## Options
92
+
93
+ | Flag | Default | |
94
+ |------|---------|--|
95
+ | `--scan DIR` | | find repos under DIR (3 levels deep) |
96
+ | `--apply` | off | actually remove |
97
+ | `--fetch` | off | `git fetch --prune` first, so merged / deleted-on-remote is current |
98
+ | `--idle H` | 24 | hours untouched before a merged worktree is removed |
99
+ | `--stale D` | 7 | days untouched before an unmerged worktree is removed |
100
+ | `--all` | off | also list KEEP rows, with the reason |
101
+ | `--json` | off | machine-readable output |
102
+
103
+ ## Use it from your agents
104
+
105
+ Paste this into `AGENTS.md` or `CLAUDE.md`:
106
+
107
+ ```
108
+ Worktrees: lock the worktree you work in with
109
+ `git worktree lock --reason "<task>" <path>` and unlock it when you finish.
110
+ To check for leftovers, run `wtclean --json`. Only run `wtclean --apply` when the user asks.
111
+ ```
112
+
113
+ `--json` returns one object per worktree: `path`, `branch`, `action` (`remove` / `prune` / `keep`), `reason`, `idle_h`.
114
+
115
+ Or run it on a schedule. This crontab line cleans up every Monday at 9am:
116
+
117
+ ```
118
+ 0 9 * * 1 ~/.local/bin/wtclean --scan ~/code --fetch --apply >> ~/.wtclean.log 2>&1
119
+ ```
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ ./test.sh # builds a scratch repo covering every case and checks each verdict
125
+ ```
126
+
127
+ One file, standard library only. MIT licensed.
@@ -0,0 +1,6 @@
1
+ git_wtclean.py,sha256=XcqKu5RKfcl8VW7nswhFb1wHFWcSCosEIOp4dxRuV1U,10475
2
+ git_wtclean-0.1.0.dist-info/METADATA,sha256=UTiPu7e7QkJra6exllcRbFOxY2WcD1UGe_h54jr8wJs,5489
3
+ git_wtclean-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ git_wtclean-0.1.0.dist-info/entry_points.txt,sha256=KAcDFnBU8DAAImvFwQ8h_olVcS1_cFkrkdFGBGg4tm4,76
5
+ git_wtclean-0.1.0.dist-info/licenses/LICENSE,sha256=cMeaBzF1RXlP4wmBGUnB5jf7TPj7e_zdw2WMJedYsBk,1062
6
+ git_wtclean-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ git-wtclean = git_wtclean:main
3
+ wtclean = git_wtclean:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ar9av
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.
git_wtclean.py ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env python3
2
+ """wtclean - find and remove dead git worktrees left behind by coding agents.
3
+
4
+ Dry-run by default. Removal goes through `git worktree remove` (no --force),
5
+ so git itself refuses anything dirty. Branches are only deleted with
6
+ `git branch -d`, which refuses unmerged work. Nothing is ever rm -rf'd.
7
+
8
+ wtclean # report for the repo you're in
9
+ wtclean ~/code/a ~/code/b # several repos
10
+ wtclean --scan ~/code # every repo under a dir (depth 3)
11
+ wtclean --apply # actually remove the REMOVE rows
12
+ wtclean --json # machine-readable, for agents
13
+ """
14
+ __version__ = "0.1.0"
15
+
16
+ import argparse, filecmp, json, os, shutil, subprocess, sys, time
17
+ from concurrent.futures import ThreadPoolExecutor
18
+
19
+ os.environ["GIT_OPTIONAL_LOCKS"] = "0" # read-only scans must not rewrite indexes (that resets the idle clock)
20
+
21
+
22
+ def git(cwd, *args):
23
+ r = subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True)
24
+ return r.returncode, r.stdout.strip(), r.stderr.strip()
25
+
26
+
27
+ def worktrees(repo):
28
+ _, out, _ = git(repo, "worktree", "list", "--porcelain")
29
+ wts, cur = [], {}
30
+ for line in out.splitlines() + [""]:
31
+ if not line:
32
+ if cur:
33
+ wts.append(cur)
34
+ cur = {}
35
+ continue
36
+ k, _, v = line.partition(" ")
37
+ cur[k] = v or True
38
+ return wts # first entry is the main worktree
39
+
40
+
41
+ def default_branch(repo):
42
+ rc, out, _ = git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
43
+ if rc == 0:
44
+ return out
45
+ for b in ("origin/main", "origin/master", "main", "master"):
46
+ if git(repo, "rev-parse", "--verify", "-q", b)[0] == 0:
47
+ return b
48
+ return "HEAD"
49
+
50
+
51
+ def idle_hours(repo, path):
52
+ # last activity = newest of the worktree's index / HEAD in its git admin dir
53
+ _, gitdir, _ = git(path, "rev-parse", "--absolute-git-dir")
54
+ ts = [os.path.getmtime(os.path.join(gitdir, f))
55
+ for f in ("index", "HEAD", "logs/HEAD") if os.path.exists(os.path.join(gitdir, f))]
56
+ return (time.time() - max(ts)) / 3600 if ts else 1e9
57
+
58
+
59
+ def busy_dirs():
60
+ # cwd of every running process: a dev server, shell or agent sitting in a worktree means it's in use
61
+ try:
62
+ r = subprocess.run(["lsof", "-a", "-d", "cwd", "-Fn"], capture_output=True, text=True)
63
+ return {l[1:] for l in r.stdout.splitlines() if l.startswith("n")}
64
+ except FileNotFoundError: # Linux without lsof
65
+ out = set()
66
+ for pid in os.listdir("/proc") if os.path.isdir("/proc") else []:
67
+ try:
68
+ out.add(os.readlink(f"/proc/{pid}/cwd"))
69
+ except OSError:
70
+ pass
71
+ return out
72
+
73
+
74
+ BUSY = None
75
+
76
+
77
+ def unique_secrets(path, main):
78
+ # ignored .env-style files are deleted with the worktree; flag ones the main checkout doesn't have
79
+ out = []
80
+ for d, subs, files in os.walk(path):
81
+ subs[:] = [s for s in subs if s not in (".git", "node_modules", ".venv", "venv")]
82
+ if d[len(path):].count(os.sep) >= 3:
83
+ subs[:] = []
84
+ for f in files:
85
+ if f.startswith(".env") and not f.endswith((".example", ".sample", ".template")):
86
+ rel = os.path.relpath(os.path.join(d, f), path)
87
+ twin = os.path.join(main, rel)
88
+ if not (os.path.isfile(twin) and filecmp.cmp(twin, os.path.join(d, f), shallow=False)):
89
+ out.append(rel)
90
+ return out
91
+
92
+
93
+ def classify(repo, wt, base, gone, idle_min, stale_h):
94
+ path, sha = wt["worktree"], wt.get("HEAD", "")
95
+ branch = wt.get("branch", "").removeprefix("refs/heads/") or None
96
+ row = {"repo": repo, "path": path, "branch": branch or f"(detached {sha[:7]})",
97
+ "action": "keep", "reason": ""}
98
+ if wt.get("prunable") or not os.path.isdir(path):
99
+ return {**row, "action": "prune", "reason": "dir missing"}
100
+ if wt.get("locked"):
101
+ return {**row, "reason": "locked"}
102
+ if any(b == path or b.startswith(path + "/") for b in BUSY):
103
+ return {**row, "reason": "process running here"}
104
+ idle = idle_hours(repo, path) # before status: status may rewrite the index and reset the clock
105
+ row["idle_h"] = round(idle)
106
+ rc, dirty, _ = git(path, "status", "--porcelain")
107
+ if rc or dirty:
108
+ return {**row, "reason": f"uncommitted changes ({plural(len(dirty.splitlines()), 'file')})" if not rc else "status failed"}
109
+ if idle < idle_min:
110
+ return {**row, "reason": f"active {idle:.0f}h ago"}
111
+ secrets = unique_secrets(path, repo)
112
+ if secrets:
113
+ return {**row, "reason": f"has {secrets[0]} not in main checkout"}
114
+ merged = git(repo, "merge-base", "--is-ancestor", sha, base)[0] == 0
115
+ if merged:
116
+ return {**row, "action": "remove", "reason": f"merged into {base}", "del_branch": bool(branch)}
117
+ if branch in gone:
118
+ return {**row, "action": "remove", "reason": "remote branch gone (PR closed/squashed)"}
119
+ if not branch: # detached: only safe if its commits live on some remote ref
120
+ _, on_remote, _ = git(repo, "for-each-ref", "--contains", sha, "--count=1", "refs/remotes")
121
+ if not on_remote:
122
+ return {**row, "reason": "detached with unpushed commits"}
123
+ # clean, idle, unmerged: removing the worktree is lossless, the branch keeps the commits,
124
+ # but in-flight work gets a longer grace period than merged/abandoned branches
125
+ if idle < stale_h:
126
+ return {**row, "reason": f"unmerged, idle {idle / 24:.0f}d < --stale"}
127
+ return {**row, "action": "remove", "reason": f"idle {idle / 24:.0f}d, branch kept"}
128
+
129
+
130
+ def scan_repo(repo, idle_min, stale_h):
131
+ wts = worktrees(repo)
132
+ if not wts:
133
+ return []
134
+ main = wts[0]["worktree"]
135
+ base = default_branch(main)
136
+ _, refs, _ = git(main, "for-each-ref", "--format=%(refname:short)|%(upstream:track)", "refs/heads")
137
+ gone = {l.split("|")[0] for l in refs.splitlines() if "[gone]" in l}
138
+ with ThreadPoolExecutor(16) as ex:
139
+ return list(ex.map(lambda w: classify(main, w, base, gone, idle_min, stale_h), wts[1:]))
140
+
141
+
142
+ def find_repos(root, depth=3):
143
+ root = os.path.expanduser(root)
144
+ for d, subs, _ in os.walk(root):
145
+ if ".git" in subs and os.path.isdir(os.path.join(d, ".git")): # skip linked worktrees (.git file)
146
+ yield d
147
+ subs[:] = []
148
+ elif d[len(root):].count(os.sep) >= depth:
149
+ subs[:] = []
150
+ else:
151
+ subs[:] = [s for s in subs if not s.startswith(".") and s != "node_modules"]
152
+
153
+
154
+ def plural(n, word):
155
+ return f"{n} {word}{'' if n == 1 else 's'}"
156
+
157
+
158
+ def apply(rows):
159
+ todo = [r for r in rows if r["action"] == "remove"]
160
+ for i, r in enumerate(todo, 1):
161
+ if sys.stderr.isatty(): # big node_modules trees take a while; show we're alive
162
+ line = f"[{i}/{len(todo)}] removing {r['path'].replace(os.path.expanduser('~'), '~')}"
163
+ sys.stderr.write("\r\033[K" + line[:shutil.get_terminal_size().columns - 1])
164
+ sys.stderr.flush()
165
+ rc, _, err = git(r["repo"], "worktree", "remove", r["path"])
166
+ r["result"] = "removed" if rc == 0 else f"failed: {err.splitlines()[-1] if err else rc}"
167
+ if rc == 0 and r.get("del_branch") and git(r["repo"], "branch", "-d", r["branch"])[0] == 0:
168
+ r["result"] += " +branch"
169
+ if todo and sys.stderr.isatty():
170
+ sys.stderr.write("\r\033[K")
171
+ for repo in {r["repo"] for r in rows}:
172
+ git(repo, "worktree", "prune")
173
+ for r in rows:
174
+ if r["action"] == "prune":
175
+ r["result"] = "pruned"
176
+
177
+
178
+ def main():
179
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
180
+ p.add_argument("--version", action="version", version=f"wtclean {__version__}")
181
+ p.add_argument("repos", nargs="*", help="repo paths (default: current repo)")
182
+ p.add_argument("--scan", metavar="DIR", help="find repos under DIR")
183
+ p.add_argument("--idle", type=float, default=24, help="hours untouched before a worktree counts as dead (default 24)")
184
+ p.add_argument("--stale", type=float, default=7, help="days idle before an UNMERGED worktree counts as dead (default 7)")
185
+ p.add_argument("--apply", action="store_true", help="remove the REMOVE rows (default: dry run)")
186
+ p.add_argument("--fetch", action="store_true", help="git fetch --prune first, so merged/gone is current")
187
+ p.add_argument("--json", action="store_true", help="print JSON (for agents)")
188
+ p.add_argument("--all", action="store_true", help="also list KEEP rows")
189
+ a = p.parse_args()
190
+ global BUSY
191
+ BUSY = busy_dirs()
192
+
193
+ repos = list(a.repos) or ([] if a.scan else ["."])
194
+ if a.scan:
195
+ repos += list(find_repos(a.scan))
196
+ found = set()
197
+ for r in repos:
198
+ rc, d, _ = git(os.path.expanduser(r), "rev-parse", "--path-format=absolute", "--git-common-dir")
199
+ if rc:
200
+ sys.exit(f"wtclean: {r} is not a git repo. Run it inside a repo, or use --scan DIR.")
201
+ found.add(d.removesuffix("/.git"))
202
+ repos = found
203
+ if a.fetch:
204
+ with ThreadPoolExecutor(8) as ex:
205
+ list(ex.map(lambda r: git(r, "fetch", "--prune", "-q"), repos))
206
+ rows = [row for r in sorted(repos) for row in scan_repo(r, a.idle, a.stale * 24)]
207
+ if a.apply:
208
+ apply(rows)
209
+
210
+ if a.json:
211
+ json.dump(rows, sys.stdout, indent=1)
212
+ print()
213
+ return
214
+ home = os.path.expanduser("~")
215
+ color = sys.stdout.isatty()
216
+ paint = {"remove": "\033[31m", "prune": "\033[33m", "keep": "\033[32m"}
217
+ shown = [r for r in rows if r["action"] != "keep" or a.all]
218
+ pw = max([len(r["path"].replace(home, "~")) for r in shown] + [10])
219
+ bw = max([len(r["branch"][:38]) for r in shown] + [6])
220
+ for r in shown:
221
+ tag = r["action"].upper().ljust(6)
222
+ if color:
223
+ tag = paint[r["action"]] + tag + "\033[0m"
224
+ print(f"{tag} {r['path'].replace(home, '~'):<{pw}} {r['branch'][:38]:<{bw}} {r['reason']}"
225
+ + (f" -> {r['result']}" if "result" in r else ""))
226
+ n = {k: sum(r["action"] == k for r in rows) for k in ("remove", "prune", "keep")}
227
+ verb = "removed" if a.apply else "would remove"
228
+ print(f"\n{plural(len(rows), 'worktree')} in {plural(len(repos), 'repo')}: {verb} {n['remove']}, "
229
+ f"prune {n['prune']}, keep {n['keep']}" + ("" if a.apply else " (dry run: add --apply to do it)"))
230
+ if any(r.get("result", "").startswith("failed") for r in rows):
231
+ sys.exit(1)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()