repowatch 0.4.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.
repowatch/core.py ADDED
@@ -0,0 +1,399 @@
1
+ """Repo discovery and status. No UI code here — everything returns plain data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import asdict, dataclass, field
12
+ from pathlib import Path
13
+ from urllib.parse import urlparse
14
+
15
+ from .config import Config
16
+
17
+ SKIP_DIRS = {
18
+ "node_modules", ".venv", "venv", "env", "__pycache__", ".tox", ".mypy_cache",
19
+ ".pytest_cache", "build", "dist", "target", ".idea", ".vscode", ".gradle",
20
+ }
21
+
22
+ # Never let git block on a prompt: no terminal prompts, no credential-manager pop-ups.
23
+ _GIT_ENV_OVERRIDES = {
24
+ "GIT_TERMINAL_PROMPT": "0",
25
+ "GCM_INTERACTIVE": "never",
26
+ "LC_ALL": "C",
27
+ }
28
+
29
+
30
+ def git_env() -> dict[str, str]:
31
+ """Current environment plus the no-prompt overrides (read per call, not at import)."""
32
+ return {**os.environ, **_GIT_ENV_OVERRIDES}
33
+
34
+ _NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
35
+
36
+
37
+ def git(path: Path | str, *args: str, timeout: float = 15) -> subprocess.CompletedProcess:
38
+ return subprocess.run(
39
+ ["git", "-C", str(path), *args],
40
+ capture_output=True,
41
+ text=True,
42
+ encoding="utf-8",
43
+ errors="replace",
44
+ env=git_env(),
45
+ timeout=timeout,
46
+ stdin=subprocess.DEVNULL,
47
+ creationflags=_NO_WINDOW,
48
+ )
49
+
50
+
51
+ # --------------------------------------------------------------------------- data
52
+
53
+
54
+ @dataclass
55
+ class RemoteStatus:
56
+ name: str
57
+ label: str
58
+ url: str
59
+ host: str
60
+ ref: str | None = None # e.g. "origin/main"
61
+ sha: str | None = None
62
+ ahead: int | None = None # local commits not on remote (→ needs push)
63
+ behind: int | None = None # remote commits not in local (→ needs pull)
64
+ error: str | None = None # last fetch error, if any
65
+ mirror: bool = False # read-only mirror of another remote (e.g. a Gitea pull mirror)
66
+
67
+ @property
68
+ def state(self) -> str:
69
+ if self.error:
70
+ return "unreachable"
71
+ if self.sha is None:
72
+ return "missing"
73
+ if self.ahead is None:
74
+ return "unknown"
75
+ if self.ahead == 0 and self.behind == 0:
76
+ return "synced"
77
+ if self.ahead and self.behind:
78
+ return "diverged"
79
+ return "ahead" if self.ahead else "behind"
80
+
81
+
82
+ @dataclass
83
+ class RepoStatus:
84
+ path: str
85
+ name: str
86
+ branch: str | None = None
87
+ detached: bool = False
88
+ head: str | None = None
89
+ upstream: str | None = None
90
+ staged: int = 0
91
+ unstaged: int = 0
92
+ untracked: int = 0
93
+ conflicts: int = 0
94
+ stashes: int = 0
95
+ last_fetch: float | None = None
96
+ remotes: list[RemoteStatus] = field(default_factory=list)
97
+ error: str | None = None
98
+
99
+ @property
100
+ def dirty(self) -> bool:
101
+ return bool(self.staged or self.unstaged or self.conflicts)
102
+
103
+ def remote(self, label: str) -> RemoteStatus | None:
104
+ return next((r for r in self.remotes if r.label == label), None)
105
+
106
+ def flags(self) -> list[str]:
107
+ if self.error:
108
+ return ["error"]
109
+ out: list[str] = []
110
+ if self.conflicts:
111
+ out.append("conflicts")
112
+ if self.dirty:
113
+ out.append("dirty")
114
+ if self.untracked:
115
+ out.append("untracked")
116
+ if self.stashes:
117
+ out.append("stash")
118
+ primary = [r for r in self.remotes if not r.mirror]
119
+ mirrors = [r for r in self.remotes if r.mirror]
120
+ states = {r.state for r in primary}
121
+ if "diverged" in states:
122
+ out.append("diverged")
123
+ if "ahead" in states:
124
+ out.append("push")
125
+ if "behind" in states:
126
+ out.append("pull")
127
+ tips = {r.sha for r in primary if r.sha}
128
+ if len(tips) > 1:
129
+ out.append("remotes differ")
130
+ # A mirror is "lagging" if it doesn't match what the primary remote(s) have.
131
+ if tips and any(m.sha not in tips and m.state != "unreachable" for m in mirrors):
132
+ out.append("mirror lag")
133
+ if "unreachable" in {r.state for r in self.remotes}:
134
+ out.append("unreachable")
135
+ if not self.remotes:
136
+ out.append("no remotes")
137
+ return out
138
+
139
+ def to_dict(self) -> dict:
140
+ d = asdict(self)
141
+ d["flags"] = self.flags()
142
+ for rd, r in zip(d["remotes"], self.remotes, strict=True):
143
+ rd["state"] = r.state
144
+ return d
145
+
146
+
147
+ # ----------------------------------------------------------------------- discovery
148
+
149
+
150
+ def find_repos(root: Path | str, max_depth: int = 3) -> list[Path]:
151
+ root = Path(root).expanduser().resolve()
152
+ found: list[Path] = []
153
+
154
+ def walk(d: Path, depth: int) -> None:
155
+ if (d / ".git").exists():
156
+ found.append(d)
157
+ return # don't descend into repos (submodules/vendored repos ignored)
158
+ if depth >= max_depth:
159
+ return
160
+ try:
161
+ entries = sorted(os.scandir(d), key=lambda e: e.name.lower())
162
+ except OSError:
163
+ return
164
+ for e in entries:
165
+ try:
166
+ is_dir = e.is_dir(follow_symlinks=False)
167
+ except OSError:
168
+ continue
169
+ if is_dir and e.name not in SKIP_DIRS and not e.name.startswith("."):
170
+ walk(Path(e.path), depth + 1)
171
+
172
+ walk(root, 0)
173
+ return found
174
+
175
+
176
+ # ---------------------------------------------------------------------- remotes
177
+
178
+
179
+ _SCP_LIKE = re.compile(r"^(?:[\w.+-]+@)?(?P<host>[\w.-]+):(?!//)(?P<path>.+)$")
180
+
181
+
182
+ def url_host(url: str) -> str:
183
+ if "://" not in url:
184
+ m = _SCP_LIKE.match(url)
185
+ # Guard against Windows drive letters (C:/...) being read as hosts.
186
+ if m and len(m.group("host")) > 1:
187
+ return m.group("host").lower()
188
+ return ""
189
+ return (urlparse(url).hostname or "").lower()
190
+
191
+
192
+ def url_owner_repo(url: str) -> tuple[str, str] | None:
193
+ if "://" in url:
194
+ path = urlparse(url).path
195
+ else:
196
+ m = _SCP_LIKE.match(url)
197
+ path = m.group("path") if m else url
198
+ parts = [p for p in re.split(r"[\\/]", path) if p]
199
+ if len(parts) < 2:
200
+ return None
201
+ repo = parts[-1].removesuffix(".git")
202
+ return parts[-2], repo
203
+
204
+
205
+ def label_for(remote_name: str, url: str, hosts: dict[str, str]) -> str:
206
+ host = url_host(url)
207
+ if host:
208
+ if host in hosts:
209
+ return hosts[host]
210
+ for key, lab in hosts.items():
211
+ if key in host:
212
+ return lab
213
+ if "gitea" in host:
214
+ return "gitea"
215
+ if "github" in host:
216
+ return "github"
217
+ return remote_name
218
+
219
+
220
+ def list_remotes(path: Path | str) -> dict[str, str]:
221
+ """Remote name -> URL as configured (before any url.insteadOf rewriting)."""
222
+ out: dict[str, str] = {}
223
+ cp = git(path, "config", "--local", "--get-regexp", r"^remote\..*\.url$")
224
+ for line in cp.stdout.splitlines():
225
+ key, _, url = line.partition(" ")
226
+ name = key[len("remote."):-len(".url")]
227
+ out.setdefault(name, url.strip())
228
+ return out
229
+
230
+
231
+ # ----------------------------------------------------------------------- status
232
+
233
+
234
+ def _parse_status(text: str, st: RepoStatus) -> None:
235
+ for line in text.splitlines():
236
+ if line.startswith("# branch.oid "):
237
+ oid = line.split(" ", 2)[2]
238
+ st.head = None if oid == "(initial)" else oid
239
+ elif line.startswith("# branch.head "):
240
+ head = line.split(" ", 2)[2]
241
+ st.detached = head == "(detached)"
242
+ st.branch = None if st.detached else head
243
+ elif line.startswith("# branch.upstream "):
244
+ st.upstream = line.split(" ", 2)[2]
245
+ elif line.startswith(("1 ", "2 ")):
246
+ xy = line[2:4]
247
+ if xy[0] != ".":
248
+ st.staged += 1
249
+ if xy[1] != ".":
250
+ st.unstaged += 1
251
+ elif line.startswith("u "):
252
+ st.conflicts += 1
253
+ elif line.startswith("? "):
254
+ st.untracked += 1
255
+
256
+
257
+ def repo_status(
258
+ path: Path | str,
259
+ root: Path | str,
260
+ cfg: Config,
261
+ fetch_errors: dict[str, str] | None = None,
262
+ ) -> RepoStatus:
263
+ path = Path(path)
264
+ try:
265
+ name = path.relative_to(Path(root).expanduser().resolve()).as_posix() or path.name
266
+ except ValueError:
267
+ name = path.name
268
+ st = RepoStatus(path=str(path), name=name or path.name)
269
+ fetch_errors = fetch_errors or {}
270
+
271
+ try:
272
+ cp = git(path, "status", "--porcelain=v2", "--branch")
273
+ if cp.returncode != 0:
274
+ st.error = (cp.stderr.strip().splitlines() or ["git status failed"])[-1]
275
+ return st
276
+ _parse_status(cp.stdout, st)
277
+
278
+ cp = git(path, "stash", "list", "--format=%h")
279
+ st.stashes = len([l for l in cp.stdout.splitlines() if l.strip()])
280
+
281
+ gd = git(path, "rev-parse", "--git-common-dir").stdout.strip() or ".git"
282
+ fh = (path / gd) / "FETCH_HEAD" # absolute gd overrides path on join
283
+ if fh.exists():
284
+ st.last_fetch = fh.stat().st_mtime
285
+
286
+ remotes = list_remotes(path)
287
+ cp = git(path, "for-each-ref", "--format=%(refname:strip=2)%09%(objectname)", "refs/remotes")
288
+ refs = dict(l.split("\t", 1) for l in cp.stdout.splitlines() if "\t" in l)
289
+
290
+ used_labels: set[str] = set()
291
+ for rname, url in remotes.items():
292
+ label = label_for(rname, url, cfg.hosts)
293
+ if label in used_labels:
294
+ label = f"{label}:{rname}"
295
+ used_labels.add(label)
296
+ rs = RemoteStatus(name=rname, label=label, url=url, host=url_host(url),
297
+ error=fetch_errors.get(rname),
298
+ mirror=label.split(":")[0] in cfg.mirror_labels)
299
+
300
+ if st.branch:
301
+ candidates = [f"{rname}/{st.branch}"]
302
+ else: # detached: compare to the remote's default branch
303
+ candidates = [f"{rname}/HEAD", f"{rname}/main", f"{rname}/master"]
304
+ for cand in candidates:
305
+ if cand in refs:
306
+ rs.ref, rs.sha = cand, refs[cand]
307
+ break
308
+
309
+ if rs.sha and st.head:
310
+ if rs.sha == st.head:
311
+ rs.ahead = rs.behind = 0
312
+ else:
313
+ cp = git(path, "rev-list", "--left-right", "--count", f"HEAD...{rs.sha}")
314
+ if cp.returncode == 0 and cp.stdout.strip():
315
+ a, b = cp.stdout.split()
316
+ rs.ahead, rs.behind = int(a), int(b)
317
+ st.remotes.append(rs)
318
+ except subprocess.TimeoutExpired:
319
+ st.error = "git timed out"
320
+ except Exception as exc: # keep one broken repo from killing the whole scan
321
+ st.error = f"{type(exc).__name__}: {exc}"
322
+ return st
323
+
324
+
325
+ # ------------------------------------------------------------------------ fetch
326
+
327
+
328
+ def _ssh_override(path: Path | str) -> list[str]:
329
+ """Make ssh fail fast instead of prompting, unless the user has their own sshCommand."""
330
+ cp = git(path, "config", "--get", "core.sshCommand")
331
+ if cp.returncode == 0 and cp.stdout.strip():
332
+ return []
333
+ return ["-c", "core.sshCommand=ssh -o BatchMode=yes -o ConnectTimeout=8"]
334
+
335
+
336
+ def fetch_repo(path: Path | str, timeout: float = 25, remotes: list[str] | None = None) -> dict[str, str]:
337
+ """Fetch each remote separately so one unreachable remote doesn't block the others.
338
+ Returns {remote_name: error} for the ones that failed."""
339
+ errors: dict[str, str] = {}
340
+ names = remotes or list(list_remotes(path))
341
+ ssh = _ssh_override(path)
342
+ for rname in names:
343
+ try:
344
+ cp = git(path, *ssh, "fetch", "--prune", "--quiet", rname, timeout=timeout)
345
+ if cp.returncode != 0:
346
+ lines = [l.strip() for l in cp.stderr.splitlines() if l.strip()]
347
+ key = next((l for l in lines if l.startswith(("fatal:", "error:"))), None)
348
+ errors[rname] = key or (lines[0] if lines else f"fetch failed ({cp.returncode})")
349
+ except subprocess.TimeoutExpired:
350
+ errors[rname] = f"timed out after {timeout:.0f}s"
351
+ return errors
352
+
353
+
354
+ def add_remote(path: Path | str, name: str, url: str) -> str | None:
355
+ cp = git(path, "remote", "add", name, url)
356
+ if cp.returncode != 0:
357
+ return (cp.stderr.strip().splitlines() or ["git remote add failed"])[-1]
358
+ return None
359
+
360
+
361
+ # ------------------------------------------------------------------------- bulk
362
+
363
+
364
+ def scan(
365
+ root: Path | str,
366
+ cfg: Config,
367
+ do_fetch: bool = False,
368
+ workers: int = 8,
369
+ fetch_errors: dict[str, dict[str, str]] | None = None,
370
+ ) -> list[RepoStatus]:
371
+ repos = find_repos(root, cfg.depth)
372
+ fetch_errors = fetch_errors if fetch_errors is not None else {}
373
+
374
+ def one(p: Path) -> RepoStatus:
375
+ if do_fetch:
376
+ fetch_errors[str(p)] = fetch_repo(p, cfg.fetch_timeout)
377
+ return repo_status(p, root, cfg, fetch_errors.get(str(p)))
378
+
379
+ with ThreadPoolExecutor(max_workers=workers) as pool:
380
+ return list(pool.map(one, repos))
381
+
382
+
383
+ def all_labels(statuses: list[RepoStatus], order: list[str]) -> list[str]:
384
+ seen = {r.label for s in statuses for r in s.remotes}
385
+ first = [l for l in order if l in seen]
386
+ return first + sorted(seen - set(first))
387
+
388
+
389
+ def ago(ts: float | None) -> str:
390
+ if not ts:
391
+ return "never"
392
+ d = time.time() - ts
393
+ if d < 90:
394
+ return "just now"
395
+ if d < 3600:
396
+ return f"{d / 60:.0f}m ago"
397
+ if d < 86400 * 2:
398
+ return f"{d / 3600:.0f}h ago"
399
+ return f"{d / 86400:.0f}d ago"
repowatch/prune.py ADDED
@@ -0,0 +1,209 @@
1
+ """Find and delete stale branches on the primary remote (GitHub).
2
+
3
+ A branch is deletable when its work is already in the default branch:
4
+ merged its tip is an ancestor of main (merge commit / fast-forward / rebase)
5
+ squash-merged its combined change already exists in main as one squashed commit
6
+
7
+ Unmerged branches are only reported, unless --include-unmerged DAYS is given, and then
8
+ only those older than DAYS with no open pull request (needs the GitHub CLI `gh`).
9
+ Never touched: the default branch, anything matching prune_keep, branches younger than
10
+ prune_min_age_days, and anything checked out locally.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import shutil
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from fnmatch import fnmatchcase
20
+ from pathlib import Path
21
+
22
+ from . import core
23
+ from .config import Config
24
+
25
+
26
+ @dataclass
27
+ class Branch:
28
+ name: str
29
+ sha: str
30
+ age_days: int
31
+ kind: str # merged | squash-merged | unmerged
32
+ ahead: int = 0 # commits not in default (unmerged only)
33
+ delete: bool = False
34
+ reason: str = "" # why kept
35
+ deleted: bool | None = None
36
+
37
+
38
+ @dataclass
39
+ class PruneResult:
40
+ path: str
41
+ name: str
42
+ remote: str = ""
43
+ default: str = ""
44
+ branches: list[Branch] = field(default_factory=list)
45
+ skipped: str | None = None
46
+ note: str | None = None
47
+ error: str | None = None
48
+
49
+ @property
50
+ def to_delete(self) -> list[Branch]:
51
+ return [b for b in self.branches if b.delete]
52
+
53
+
54
+ _ID = ["-c", "user.name=repowatch", "-c", "user.email=repowatch@localhost"]
55
+
56
+
57
+ def _squash_merged(path: str, sha: str, default_ref: str) -> bool:
58
+ """True if the branch's net change is already in default (the git-delete-squashed trick)."""
59
+ mb = core.git(path, "merge-base", default_ref, sha).stdout.strip()
60
+ if not mb:
61
+ return False
62
+ tree = core.git(path, "rev-parse", f"{sha}^{{tree}}").stdout.strip()
63
+ tmp = core.git(path, *_ID, "commit-tree", tree, "-p", mb, "-m", "repowatch-squash-probe").stdout.strip()
64
+ if not tmp:
65
+ return False
66
+ out = core.git(path, "cherry", default_ref, tmp).stdout.strip()
67
+ return out.startswith("-")
68
+
69
+
70
+ def _open_pr_heads(url: str) -> set[str] | None:
71
+ """Head branch names of open PRs, via `gh`. None if gh is missing or fails."""
72
+ if not shutil.which("gh") or "github.com" not in url:
73
+ return None
74
+ owner_repo = core.url_owner_repo(url)
75
+ if not owner_repo:
76
+ return None
77
+ import subprocess
78
+
79
+ try:
80
+ cp = subprocess.run(
81
+ ["gh", "pr", "list", "-R", "/".join(owner_repo), "--state", "open",
82
+ "--json", "headRefName", "--limit", "500"],
83
+ capture_output=True, text=True, encoding="utf-8", timeout=30,
84
+ creationflags=core._NO_WINDOW,
85
+ )
86
+ if cp.returncode != 0:
87
+ return None
88
+ return {pr["headRefName"] for pr in json.loads(cp.stdout or "[]")}
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def plan_and_run(path: str, root: str, cfg: Config, apply: bool = False,
94
+ include_unmerged_days: int | None = None, min_age_days: int | None = None) -> PruneResult:
95
+ status = core.repo_status(path, root, cfg)
96
+ res = PruneResult(path=str(path), name=status.name)
97
+ if status.error:
98
+ res.skipped = status.error
99
+ return res
100
+ primary = next((r for r in status.remotes if r.label.split(":")[0] in cfg.primary_labels), None)
101
+ if primary is None:
102
+ res.skipped = "no primary remote"
103
+ return res
104
+ res.remote = primary.label
105
+
106
+ errs = core.fetch_repo(path, cfg.fetch_timeout, remotes=[primary.name])
107
+ if errs:
108
+ res.skipped = f"{primary.label} unreachable: {errs[primary.name]}"
109
+ return res
110
+
111
+ # default branch
112
+ cp = core.git(path, "symbolic-ref", "--short", f"refs/remotes/{primary.name}/HEAD")
113
+ if cp.returncode != 0:
114
+ core.git(path, "remote", "set-head", primary.name, "--auto", timeout=cfg.fetch_timeout)
115
+ cp = core.git(path, "symbolic-ref", "--short", f"refs/remotes/{primary.name}/HEAD")
116
+ default_ref = cp.stdout.strip()
117
+ if not default_ref:
118
+ for cand in ("main", "master"):
119
+ if core.git(path, "rev-parse", "--verify", "-q", f"{primary.name}/{cand}").returncode == 0:
120
+ default_ref = f"{primary.name}/{cand}"
121
+ break
122
+ if not default_ref:
123
+ res.skipped = "can't tell the default branch"
124
+ return res
125
+ default = default_ref.split("/", 1)[1]
126
+ res.default = default
127
+
128
+ min_age = cfg.prune_min_age_days if min_age_days is None else min_age_days
129
+ keep = set(cfg.prune_keep) | {default}
130
+ local_heads = set(core.git(path, "for-each-ref", "--format=%(refname:short)", "refs/heads").stdout.split())
131
+
132
+ cp = core.git(path, "for-each-ref", "--format=%(refname:strip=3)%09%(objectname)%09%(committerdate:unix)",
133
+ f"refs/remotes/{primary.name}")
134
+ now = time.time()
135
+ open_prs: set[str] | None = None
136
+ if include_unmerged_days is not None:
137
+ open_prs = _open_pr_heads(primary.url)
138
+ if open_prs is None:
139
+ res.note = "unmerged branches not deleted: open-PR check needs the GitHub CLI (gh) signed in"
140
+
141
+ for line in cp.stdout.splitlines():
142
+ parts = line.split("\t")
143
+ if len(parts) != 3 or parts[0] in ("HEAD", default):
144
+ continue
145
+ name, sha, ts = parts
146
+ age = int((now - int(ts)) / 86400) if ts.isdigit() else 0
147
+ if core.git(path, "merge-base", "--is-ancestor", sha, default_ref).returncode == 0:
148
+ kind = "merged"
149
+ elif _squash_merged(path, sha, default_ref):
150
+ kind = "squash-merged"
151
+ else:
152
+ kind = "unmerged"
153
+ b = Branch(name=name, sha=sha, age_days=age, kind=kind)
154
+ if kind == "unmerged":
155
+ b.ahead = int(core.git(path, "rev-list", "--count", f"{default_ref}..{sha}").stdout.strip() or 0)
156
+
157
+ if any(fnmatchcase(name, pat) for pat in keep):
158
+ b.reason = "kept by prune_keep"
159
+ elif name in local_heads and name == status.branch:
160
+ b.reason = "checked out locally"
161
+ elif age < min_age:
162
+ b.reason = f"younger than {min_age}d"
163
+ elif kind != "unmerged":
164
+ b.delete = True
165
+ elif include_unmerged_days is None:
166
+ b.reason = "unmerged"
167
+ elif open_prs is None:
168
+ b.reason = "unmerged (no PR check)"
169
+ elif name in open_prs:
170
+ b.reason = "open PR"
171
+ elif age < include_unmerged_days:
172
+ b.reason = f"unmerged, younger than {include_unmerged_days}d"
173
+ else:
174
+ b.delete = True
175
+ res.branches.append(b)
176
+
177
+ order = {"merged": 0, "squash-merged": 1, "unmerged": 2}
178
+ res.branches.sort(key=lambda b: (not b.delete, order[b.kind], b.name))
179
+
180
+ if apply and res.to_delete:
181
+ names = [b.name for b in res.to_delete]
182
+ ssh = core._ssh_override(path)
183
+ for i in range(0, len(names), 50): # batches keep the command line short
184
+ core.git(path, *ssh, "push", primary.name, "--delete", *names[i:i + 50],
185
+ timeout=cfg.fetch_timeout * 4)
186
+ # Verify against the remote rather than parsing push output.
187
+ cp = core.git(path, *ssh, "ls-remote", "--heads", primary.name, timeout=cfg.fetch_timeout)
188
+ remaining = {l.split("refs/heads/", 1)[1] for l in cp.stdout.splitlines() if "refs/heads/" in l}
189
+ if cp.returncode != 0:
190
+ res.error = "couldn't verify deletions: " + (cp.stderr.strip().splitlines() or ["ls-remote failed"])[-1]
191
+ for b in res.to_delete:
192
+ b.deleted = None if cp.returncode != 0 else (b.name not in remaining)
193
+ core.git(path, "fetch", "--prune", "--quiet", primary.name, timeout=cfg.fetch_timeout)
194
+ return res
195
+
196
+
197
+ def prune_all(root: str, cfg: Config, apply: bool = False, names: list[str] | None = None,
198
+ include_unmerged_days: int | None = None, min_age_days: int | None = None,
199
+ workers: int = 4) -> list[PruneResult]:
200
+ from concurrent.futures import ThreadPoolExecutor
201
+
202
+ root = str(Path(root).expanduser().resolve())
203
+ repos = [str(p) for p in core.find_repos(root, cfg.depth)]
204
+ if names:
205
+ wanted = {n.lower() for n in names}
206
+ repos = [p for p in repos if Path(p).name.lower() in wanted
207
+ or Path(p).relative_to(root).as_posix().lower() in wanted]
208
+ with ThreadPoolExecutor(max_workers=workers) as pool:
209
+ return list(pool.map(lambda p: plan_and_run(p, root, cfg, apply, include_unmerged_days, min_age_days), repos))