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
|
+

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