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/__init__.py +3 -0
- repowatch/__main__.py +5 -0
- repowatch/cli.py +284 -0
- repowatch/config.py +124 -0
- repowatch/core.py +399 -0
- repowatch/prune.py +209 -0
- repowatch/render.py +123 -0
- repowatch/sync.py +182 -0
- repowatch/tui.py +402 -0
- repowatch-0.4.0.dist-info/METADATA +218 -0
- repowatch-0.4.0.dist-info/RECORD +15 -0
- repowatch-0.4.0.dist-info/WHEEL +5 -0
- repowatch-0.4.0.dist-info/entry_points.txt +2 -0
- repowatch-0.4.0.dist-info/licenses/LICENSE +21 -0
- repowatch-0.4.0.dist-info/top_level.txt +1 -0
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))
|