gitpro 0.3.1__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.
gitpro/lost.py ADDED
@@ -0,0 +1,874 @@
1
+ """lost: unpushed branches, gone upstreams, stashes, dirty trees, repos with no remote, gone remotes,
2
+ duplicate clones, stale and empty repos across the scan roots (spec 03).
3
+
4
+ Read-only by design: every git call runs with --no-optional-locks (so `status` never refreshes the
5
+ index), gc/maintenance/fsmonitor off, every transport protocol disallowed and GIT_NO_LAZY_FETCH set, so
6
+ nothing fetches, not even a partial clone's lazy fetch; status skips rename detection and LFS filters.
7
+ What git can still run: a clean filter of another driver (.gitattributes filter=<x>) when status must
8
+ re-hash a file whose stat data changed. A suggested fix is only printed, and never a destructive one.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import contextlib
14
+ import os
15
+ import re
16
+ import shlex
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Dict, List, Optional, Sequence, Set, Tuple
25
+
26
+ from . import jsonout
27
+ from .args import capability, fail, guarded
28
+ from .config import ConfigError
29
+
30
+ KINDS = ("unpushed", "merged_gone", "stash", "dirty", "no_remote", "remote_gone", "duplicate", "stale", "empty")
31
+ OFFLINE_KINDS = tuple(k for k in KINDS if k != "remote_gone")
32
+ KEY = {"stash": "stashes", "duplicate": "duplicates"} # kind -> JSON key (the rest are the same)
33
+ COUNTED = ("unpushed", "merged_gone", "stashes", "dirty", "no_remote", "remote_gone", "duplicates")
34
+ GIT_TIMEOUT = 10
35
+ WORKERS = 4
36
+ SENDS = "owner/repo names only"
37
+ _REDIRECT_VARS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR",
38
+ "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE")
39
+ _SAFE = ["--no-optional-locks", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "core.fsmonitor=false",
40
+ # No transport at all: a lazy fetch (partial clone, git < 2.44 ignores GIT_NO_LAZY_FETCH) dies before it
41
+ # connects. -c reaches the `git fetch` child through GIT_CONFIG_PARAMETERS.
42
+ "-c", "protocol.allow=never",
43
+ *[x for p in ("file", "git", "ssh", "http", "https", "ext") for x in ("-c", f"protocol.{p}.allow=never")]]
44
+ # status only: no split-index write, no LFS process/clean filter.
45
+ _STATUS = ["-c", "core.splitIndex=false", "-c", "filter.lfs.process=", "-c", "filter.lfs.clean=",
46
+ "-c", "filter.lfs.smudge=", "-c", "filter.lfs.required=false"]
47
+ DUP_DETAILS = 10
48
+ _NOTHING = "; nothing to check"
49
+ _AHEAD = re.compile(r"\bahead (\d+)")
50
+
51
+
52
+ def key(kind: str) -> str:
53
+ return KEY.get(kind, kind)
54
+
55
+
56
+ def plural(n: int, word: str) -> str:
57
+ return f"{n} {word}{'s' * (n != 1)}"
58
+
59
+
60
+ # ---------------------------------------------------------------- git
61
+
62
+ class _Timeout(Exception):
63
+ pass
64
+
65
+
66
+ def _env(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
67
+ env = {k: v for k, v in os.environ.items() if k not in _REDIRECT_VARS}
68
+ env.update(GIT_TERMINAL_PROMPT="0", GIT_OPTIONAL_LOCKS="0", GIT_NO_LAZY_FETCH="1", LC_ALL="C", LANGUAGE="C")
69
+ env.update(extra or {})
70
+ return env
71
+
72
+
73
+ def _first(text: str) -> str:
74
+ return next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
75
+
76
+
77
+ def _kill(p: subprocess.Popen) -> None:
78
+ try:
79
+ os.killpg(p.pid, signal.SIGKILL) # git and whatever it started (hooks, filters, a fetch)
80
+ except (AttributeError, OSError):
81
+ p.kill()
82
+
83
+
84
+ def _run(cmd: List[str], env: Dict[str, str], timeout: float) -> subprocess.CompletedProcess:
85
+ """cmd in a session of its own, output captured as text; on timeout its whole process group is killed
86
+ (no orphaned children) and TimeoutExpired raised."""
87
+ with subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
88
+ text=True, errors="replace", env=env, start_new_session=True) as p:
89
+ try:
90
+ out, err = p.communicate(timeout=timeout)
91
+ except subprocess.TimeoutExpired:
92
+ _kill(p)
93
+ with contextlib.suppress(subprocess.TimeoutExpired):
94
+ p.communicate(timeout=5)
95
+ raise
96
+ return subprocess.CompletedProcess(cmd, p.returncode, out, err)
97
+
98
+
99
+ class _Git:
100
+ """git -C <repo> calls for one repo; a failure becomes a warning and None, a timeout raises _Timeout."""
101
+
102
+ def __init__(self, repo: str, warnings: List[str]):
103
+ self.repo, self.warnings = repo, warnings
104
+
105
+ def raw(self, *args: str, extra: Optional[Dict[str, str]] = None, pre: Sequence[str] = ()
106
+ ) -> subprocess.CompletedProcess:
107
+ cmd = ["git", *_SAFE, *pre, "-C", self.repo, *args]
108
+ try:
109
+ return _run(cmd, _env(extra), GIT_TIMEOUT)
110
+ except subprocess.TimeoutExpired:
111
+ raise _Timeout(args[0]) from None
112
+
113
+ def __call__(self, *args: str, ok: Sequence[int] = (0,), pre: Sequence[str] = ()) -> Optional[str]:
114
+ r = self.raw(*args, pre=pre)
115
+ if r.returncode not in ok:
116
+ self.warnings.append(f"{self.repo}: git {args[0]} failed: {_first(r.stderr) or f'exit {r.returncode}'}")
117
+ return None
118
+ return r.stdout
119
+
120
+ def count(self, *args: str) -> Optional[int]:
121
+ out = self("rev-list", "--count", *args)
122
+ try:
123
+ return int(out.strip()) if out is not None else None
124
+ except ValueError:
125
+ return None
126
+
127
+
128
+ # ---------------------------------------------------------------- layout
129
+
130
+ def _layout(path: Path) -> dict:
131
+ """{"path", "common", "bare"} for a found directory, or {"path", "skip": (reason, detail)}."""
132
+ from .scan import skip_reason
133
+ g = _Git(str(path), [])
134
+ try:
135
+ r = g.raw("rev-parse", "--path-format=absolute", "--git-common-dir", "--is-bare-repository")
136
+ if r.returncode != 0:
137
+ return {"path": path, "skip": skip_reason(path, _first(r.stderr) or "git failed")}
138
+ common, bare = r.stdout.split("\n")[:2]
139
+ if bare.strip() == "true":
140
+ return {"path": path, "bare": True, "common": os.path.normpath(common.strip())}
141
+ top = g.raw("rev-parse", "--show-toplevel")
142
+ if top.returncode != 0:
143
+ return {"path": path, "skip": skip_reason(path, _first(top.stderr) or "git failed")}
144
+ except _Timeout:
145
+ return {"path": path, "skip": ("timed out", "git rev-parse timed out")}
146
+ return {"path": Path(top.stdout.strip()), "common": os.path.normpath(common.strip())}
147
+
148
+
149
+ def _worktrees(g: _Git) -> List[str]:
150
+ """Paths of the repo's non-bare worktrees that exist, the main one first; [] when git can't list them."""
151
+ out = g("worktree", "list", "--porcelain", "-z")
152
+ if out is None:
153
+ return []
154
+ paths = []
155
+ for entry in out.split("\0\0"):
156
+ fields = dict(f.split(" ", 1) if " " in f else (f, "") for f in entry.split("\0") if f)
157
+ if "worktree" in fields and "bare" not in fields and os.path.isdir(fields["worktree"]):
158
+ paths.append(fields["worktree"])
159
+ return paths
160
+
161
+
162
+ # ---------------------------------------------------------------- one repo
163
+
164
+ def _dirty(g: _Git) -> Optional[Tuple[int, int]]:
165
+ out = g("status", "--porcelain=v2", "-z", "-unormal", "--no-renames", pre=_STATUS)
166
+ if out is None:
167
+ return None
168
+ modified = untracked = 0
169
+ toks = iter(out.split("\0"))
170
+ for t in toks:
171
+ if t.startswith(("1 ", "u ")):
172
+ modified += 1
173
+ elif t.startswith("2 "):
174
+ modified += 1
175
+ next(toks, None) # the original path of a rename/copy
176
+ elif t.startswith("? "):
177
+ untracked += 1
178
+ return modified, untracked
179
+
180
+
181
+ def _detached(g: _Git, path: str) -> Optional[dict]:
182
+ if g.raw("symbolic-ref", "-q", "HEAD").returncode == 0:
183
+ return None
184
+ if g.raw("rev-parse", "-q", "--verify", "HEAD").returncode != 0:
185
+ return None
186
+ n = g.count("HEAD", "--not", "--branches", "--remotes")
187
+ if not n:
188
+ return None
189
+ return {"repo": path, "branch": None, "commits": n, "upstream": None, "ahead": None, "detached": True,
190
+ "fix": None}
191
+
192
+
193
+ def _push_fix(repo: str, remote: str, branch: str, ref: Optional[str] = None) -> str:
194
+ return f"git -C {shlex.quote(repo)} push -u {shlex.quote(remote)} {shlex.quote(ref or branch)}"
195
+
196
+
197
+ _HEADS = "refs/heads/"
198
+
199
+
200
+ def _branches(g: _Git, repo: str, remotes: List[str], out: dict) -> None:
201
+ # %(refname), not :short: a tag of the same name makes the short name "heads/<branch>".
202
+ listing = g("for-each-ref", "--format=%(refname)%00%(upstream:short)%00%(upstream:track)", "refs/heads")
203
+ for line in (listing or "").splitlines():
204
+ parts = line.split("\0")
205
+ if len(parts) != 3 or not parts[0].startswith(_HEADS):
206
+ continue
207
+ ref, upstream, track = parts
208
+ branch = ref[len(_HEADS):]
209
+ if track == "[gone]":
210
+ n = g.count(ref, "--not", "--remotes")
211
+ if n is not None:
212
+ out["merged_gone"].append({"repo": repo, "branch": branch, "commits": n})
213
+ continue
214
+ if not remotes:
215
+ continue # no_remote covers it: there is nothing to push to
216
+ n = g.count(ref, "--not", "--remotes")
217
+ if not n:
218
+ continue
219
+ m = _AHEAD.search(track)
220
+ fix = None
221
+ if not upstream and len(remotes) == 1:
222
+ tag = g.raw("show-ref", "--verify", "-q", f"refs/tags/{branch}").returncode == 0
223
+ fix = _push_fix(repo, remotes[0], branch, ref if tag else None) # `push origin x` is ambiguous
224
+ out["unpushed"].append({"repo": repo, "branch": branch, "commits": n, "upstream": upstream or None,
225
+ "ahead": int(m[1]) if m else None, "detached": False, "fix": fix})
226
+
227
+
228
+ def _stashes(g: _Git, repo: str) -> List[dict]:
229
+ listing = g("stash", "list", "--format=%gd%x00%cI%x00%gs")
230
+ found = []
231
+ for line in (listing or "").splitlines():
232
+ parts = line.split("\0")
233
+ if len(parts) == 3:
234
+ found.append({"repo": repo, "ref": parts[0], "date": parts[1], "subject": parts[2]})
235
+ return found
236
+
237
+
238
+ def _urls(g: _Git) -> List[Tuple[str, str]]:
239
+ """[(remote, raw url)] from the repo's config, in config order."""
240
+ out = g("config", "--get-regexp", r"^remote\..*\.url$", ok=(0, 1)) or ""
241
+ pairs = []
242
+ for line in out.splitlines():
243
+ name, _, url = line.partition(" ")
244
+ pairs.append((name[len("remote."):-len(".url")], url))
245
+ return pairs
246
+
247
+
248
+ def _probe(repo: str, common: str, kinds: Set[str], stale_days: int, now: float) -> dict:
249
+ """Everything one repo (grouped by common dir; checked at its main worktree) contributes."""
250
+ warnings: List[str] = []
251
+ out: dict = {k: [] for k in ("unpushed", "merged_gone", "stashes", "dirty", "no_remote", "stale", "empty")}
252
+ out.update(warnings=warnings, worktrees=[], skipped=None, urls=[], head=None, common=common)
253
+ g = _Git(repo, warnings)
254
+ try:
255
+ trees = _worktrees(g)
256
+ if trees and os.path.normpath(trees[0]) == os.path.normpath(common):
257
+ # A submodule lists its gitdir (super/.git/modules/x) as the main tree; repo is its
258
+ # rev-parse --show-toplevel (from _layout).
259
+ trees = [repo] + [t for t in trees[1:] if t != repo]
260
+ main = trees[0] if trees else repo
261
+ out["repo"] = main
262
+ g.repo = main
263
+ out["worktrees"] = [t for t in trees if t != main]
264
+ newest = g("for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)")
265
+ has_refs = None if newest is None else bool(newest.strip())
266
+ remote_out = g("remote")
267
+ remotes = remote_out.split() if remote_out is not None else None
268
+ out["urls"] = _urls(g)
269
+ head = g.raw("rev-parse", "-q", "--verify", "HEAD")
270
+ out["head"] = head.stdout.strip() if head.returncode == 0 else None
271
+ if remotes is not None and kinds & {"unpushed", "merged_gone"}:
272
+ _branches(g, main, remotes, out)
273
+ if "stash" in kinds:
274
+ out["stashes"] = _stashes(g, main)
275
+ clean = True
276
+ for tree in [main] + out["worktrees"]:
277
+ t = _Git(tree, warnings)
278
+ d = _dirty(t) if kinds & {"dirty", "empty"} else (0, 0)
279
+ if d is None or any(d):
280
+ clean = False
281
+ if d and any(d):
282
+ out["dirty"].append({"repo": tree, "modified": d[0], "untracked": d[1]})
283
+ det = _detached(t, tree) if "unpushed" in kinds else None
284
+ if det:
285
+ out["unpushed"].append(det)
286
+ if remotes == [] and has_refs:
287
+ n = g.count("--all")
288
+ if n is not None:
289
+ out["no_remote"].append({"repo": main, "commits": n})
290
+ if has_refs:
291
+ ts = int(newest.strip())
292
+ days = int((now - ts) // 86400)
293
+ if days > stale_days:
294
+ last = datetime.fromtimestamp(ts, timezone.utc).isoformat()
295
+ out["stale"].append({"repo": main, "last_commit": last, "days": days})
296
+ elif has_refs is False and clean:
297
+ out["empty"].append({"repo": main})
298
+ except _Timeout as e:
299
+ warnings.append(f"{g.repo}: timed out; skipped")
300
+ out = {k: ([] if isinstance(v, list) else v) for k, v in out.items()}
301
+ out.update(repo=g.repo, warnings=warnings, worktrees=[],
302
+ skipped={"path": g.repo, "reason": "timed out", "error": f"git {e.args[0]} timed out"})
303
+ return out
304
+
305
+
306
+ # ---------------------------------------------------------------- duplicates
307
+
308
+ def _local_key(url: str, repo: str) -> str:
309
+ path = url[len("file://"):] if url.lower().startswith("file://") else url
310
+ p = Path(path).expanduser()
311
+ return os.path.normpath(str(p if p.is_absolute() else Path(repo) / p))
312
+
313
+
314
+ def _real_host(host: str, cfg) -> str:
315
+ host = host.lower()
316
+ for i in getattr(cfg, "identities", None) or ():
317
+ if i.ssh_alias and i.ssh_alias.lower() == host:
318
+ return i.host.lower()
319
+ return host
320
+
321
+
322
+ def dup_key(url: str, repo: str, cfg) -> str:
323
+ """host/owner/repo of a remote url, lowercased, ssh alias mapped to its host; a local path otherwise."""
324
+ from .resolve import parse_url
325
+ host, owner, name = parse_url(url)
326
+ if not host or not name:
327
+ return _local_key(url, repo)
328
+ return "/".join(x for x in (_real_host(host, cfg), (owner or "").lower(), name.lower()) if x)
329
+
330
+
331
+ def _ahead(a: dict, b: dict) -> Optional[int]:
332
+ """Commits of b's HEAD that a's HEAD lacks, reading b's objects as alternates; None when git can't tell.
333
+
334
+ --missing=print turns off rev-list's lazy fetch on every git version (fetch_if_missing = 0), so a
335
+ partial clone never asks its promisor remote for an object neither repo has (git < 2.44 ignores
336
+ GIT_NO_LAZY_FETCH); a missing commit fails the walk or prints "?<oid>", and either means "can't tell".
337
+ Only commits are walked, so a blob:none or tree:0 clone still compares."""
338
+ g = _Git(a["repo"], [])
339
+ try:
340
+ r = g.raw("rev-list", "--missing=print", f"HEAD..{b['head']}",
341
+ extra={"GIT_ALTERNATE_OBJECT_DIRECTORIES": os.path.join(b["common"], "objects")})
342
+ except _Timeout:
343
+ return None
344
+ lines = r.stdout.split()
345
+ if r.returncode != 0 or any(ln.startswith("?") for ln in lines):
346
+ return None
347
+ return len(lines)
348
+
349
+
350
+ def _duplicates(results: List[dict], cfg) -> List[dict]:
351
+ groups: Dict[str, List[dict]] = {}
352
+ for r in results:
353
+ origin = dict(r["urls"]).get("origin")
354
+ if origin and not r["skipped"]:
355
+ groups.setdefault(dup_key(origin, r["repo"], cfg), []).append(r)
356
+ found = []
357
+ for k, members in sorted(groups.items()):
358
+ if len(members) < 2:
359
+ continue
360
+ members.sort(key=lambda r: r["repo"])
361
+ first, diverged, details = members[0], False, []
362
+ for other in members[1:]:
363
+ if other["head"] == first["head"]:
364
+ continue
365
+ if not (other["head"] and first["head"]):
366
+ diverged = None if diverged is False else diverged
367
+ continue
368
+ theirs, ours = _ahead(first, other), _ahead(other, first)
369
+ if theirs is None or ours is None:
370
+ diverged = None if diverged is False else diverged
371
+ continue
372
+ for n, has, lacks in ((theirs, other, first), (ours, first, other)):
373
+ if n:
374
+ diverged = True
375
+ details.append(f"{has['repo']} has {plural(n, 'commit')} not in {lacks['repo']}")
376
+ if len(details) > DUP_DETAILS:
377
+ details = details[:DUP_DETAILS] + [f"and {len(details) - DUP_DETAILS} more"]
378
+ detail = "; ".join(details) if diverged else ("HEADs differ; could not compare" if diverged is None else None)
379
+ found.append({"key": k, "repos": [m["repo"] for m in members], "diverged": diverged, "detail": detail})
380
+ return found
381
+
382
+
383
+ # ---------------------------------------------------------------- remote_gone (online)
384
+
385
+ _TOKEN_RE = re.compile(r"\b(?:gh[pousr]_|github_pat_|glpat-)[A-Za-z0-9_\-]+")
386
+ _USERINFO_RE = re.compile(r"\b([a-zA-Z][a-zA-Z0-9+.\-]*)://([^/@\s]+)@")
387
+
388
+
389
+ def redact(text: Optional[str]) -> Optional[str]:
390
+ """text with GitHub/GitLab tokens and URL credentials replaced by [REDACTED] (an ssh:// user name stays)."""
391
+ if not text:
392
+ return text
393
+ text = _TOKEN_RE.sub("[REDACTED]", text)
394
+
395
+ def userinfo(m: "re.Match") -> str:
396
+ keep = m[1].lower() in ("ssh", "git+ssh", "ssh+git") and ":" not in m[2]
397
+ return m[0] if keep else f"{m[1]}://[REDACTED]@"
398
+ return _USERINFO_RE.sub(userinfo, text)
399
+
400
+
401
+ def _cli_guess(host: str, accounts) -> Optional[str]:
402
+ """gh or glab for a host: the CLI of a configured account there, else by name; None when unsupported."""
403
+ for a in accounts:
404
+ if a.host == host:
405
+ return a.cli
406
+ return "glab" if "gitlab" in host else "gh" if "github" in host else None
407
+
408
+
409
+ def _account(url: str, host: str, cfg, accounts) -> Tuple[Optional[object], str]:
410
+ """(account, "") to ask about a remote, or (None, reason). The identity that owns the url (resolve's
411
+ match) lends its own login on its own host, else [default] lends its github.com/gitlab.com login;
412
+ another identity's login is never borrowed."""
413
+ from .forge import GITHUB, GITLAB, Account
414
+ from .resolve import match_url
415
+ ident = match_url(url, cfg)[0] if cfg is not None and hasattr(cfg, "identities") else None
416
+ if ident is not None and ident.host.lower() == host:
417
+ for cli in ("gh", "glab"):
418
+ login = getattr(ident, f"{cli}_account", None)
419
+ if login:
420
+ return Account(ident.host, cli, login), ""
421
+ default = getattr(cfg, "default", None)
422
+ if host == GITHUB and getattr(default, "gh_account", None):
423
+ return Account(GITHUB, "gh", default.gh_account), ""
424
+ if host == GITLAB and getattr(default, "glab_account", None):
425
+ return Account(GITLAB, "glab", default.glab_account), ""
426
+ cli = _cli_guess(host, accounts)
427
+ if cli is None:
428
+ return None, f"remote check not supported for {host}"
429
+ if ident is not None:
430
+ return None, f"identity {ident.id} has no {cli}_account"
431
+ return None, f"no {cli}_account in config for {host}"
432
+
433
+
434
+ def _renamed_url(url: str, owner: str, name: str, new: str) -> Optional[str]:
435
+ m = re.search(re.escape(f"{owner}/{name}") + r"(\.git)?/?$", url, re.I)
436
+ return url[:m.start()] + new + (m[1] or "") if m else None
437
+
438
+
439
+ def remote_gone(results: List[dict], cfg, warnings: List[str], *, announce_to=None) -> List[dict]:
440
+ """Ask gh/glab whether each distinct host/owner/repo still exists; one entry per remote that does not
441
+ (status missing, renamed or unknown). One warning per host sums up its unknown answers."""
442
+ from . import forge
443
+ from .resolve import parse_url
444
+ accounts = forge.accounts(cfg) if cfg is not None and hasattr(cfg, "identities") else []
445
+ remotes = [] # (repo, remote, url, (host, owner, name))
446
+ for r in results:
447
+ if r["skipped"]:
448
+ continue
449
+ for remote, url in r["urls"]:
450
+ host, owner, name = parse_url(url)
451
+ if host and owner and name:
452
+ remotes.append((r["repo"], remote, url, (_real_host(host, cfg), owner, name)))
453
+ asks: Dict[Tuple[str, str, str], Tuple[Optional[object], str]] = {}
454
+ for _, _, url, ident in remotes:
455
+ if ident not in asks:
456
+ asks[ident] = _account(url, ident[0], cfg, accounts)
457
+ used = list(dict.fromkeys(a for a, _ in asks.values() if a is not None))
458
+ status: Dict[Tuple[str, str, str], Tuple[str, Optional[str], Optional[str]]] = {}
459
+ broken: Dict[object, str] = {} # account -> why gh/glab can't act as it (no login, CLI missing)
460
+ if used:
461
+ n = sum(a is not None for a, _ in asks.values())
462
+ with contextlib.redirect_stdout(announce_to or sys.stdout):
463
+ forge.announce(f"whether {plural(n, 'repo')} exist" if n != 1 else "whether 1 repo exists", used, SENDS)
464
+ for ident, (acc, why) in sorted(asks.items(), key=lambda kv: tuple(x.lower() for x in kv[0])):
465
+ if acc is None:
466
+ status[ident] = ("unknown", why, None)
467
+ continue
468
+ if acc in broken:
469
+ status[ident] = ("unknown", broken[acc], acc.cli)
470
+ continue
471
+ try:
472
+ st, detail = forge.repo_exists(acc, ident[1], ident[2])
473
+ except ConfigError as e:
474
+ broken[acc] = redact(str(e)) or f"no {acc.cli} login"
475
+ status[ident] = ("unknown", broken[acc], acc.cli)
476
+ continue
477
+ detail = redact(detail)
478
+ if st == "unknown" and detail and (detail.endswith("command not found") or " timed out after " in detail):
479
+ broken[acc] = detail # don't wait on a missing or hanging CLI once per repo
480
+ status[ident] = (st, detail, acc.cli)
481
+ found = []
482
+ unknown: Dict[str, Tuple[Set[str], List[str]]] = {} # host -> (repos, reasons)
483
+ for repo, remote, url, ident in remotes:
484
+ st, detail, via = status[ident]
485
+ if st == "ok":
486
+ continue
487
+ if st == "unknown":
488
+ repos, reasons = unknown.setdefault(ident[0], (set(), []))
489
+ repos.add(repo)
490
+ if detail and detail not in reasons:
491
+ reasons.append(detail)
492
+ shown = redact(url)
493
+ new = detail if st == "renamed" else None
494
+ fixed = _renamed_url(url, ident[1], ident[2], new) if new and shown == url else None
495
+ found.append({"repo": repo, "remote": remote, "url": shown, "status": st, "renamed_to": new,
496
+ "detail": detail if st == "unknown" else None,
497
+ "fix": (f"git -C {shlex.quote(repo)} remote set-url {shlex.quote(remote)} {shlex.quote(fixed)}"
498
+ if fixed else None),
499
+ "via": via})
500
+ for host, (repos, reasons) in sorted(unknown.items()):
501
+ n = plural(len(repos), "repo")
502
+ first = reasons[0] if reasons else "no answer"
503
+ more = f" (+{plural(len(reasons) - 1, 'other reason')}; -v lists them)" if len(reasons) > 1 else ""
504
+ if first == f"remote check not supported for {host}":
505
+ warnings.append(f"{first}; {n} unknown")
506
+ else:
507
+ warnings.append(f"remote check failed for {host}: {first}{more}; {n} unknown")
508
+ return sorted(found, key=lambda e: (e["repo"], e["remote"]))
509
+
510
+
511
+ # ---------------------------------------------------------------- collect
512
+
513
+ def _sorted(items: List[dict], *fields: str) -> List[dict]:
514
+ return sorted(items, key=lambda e: tuple("" if e.get(f) is None else str(e.get(f)) for f in fields))
515
+
516
+
517
+ def collect(roots: Sequence[Path], exclude: Sequence[str], cfg, *, online: bool, kinds: Set[str],
518
+ stale_days: int, repos: Optional[Sequence[Path]] = None, roots_source: str = "args",
519
+ announce_to=None) -> dict:
520
+ """The report (the --json object without ok/command). repos skips discovery. cfg may be None."""
521
+ from . import scan
522
+ if repos is None:
523
+ import inspect
524
+ params = inspect.signature(scan.find).parameters
525
+ paths = scan.find(list(roots), exclude=list(exclude)) if "exclude" in params else scan.find(list(roots))
526
+ else:
527
+ paths = list(repos)
528
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
529
+ layouts = list(pool.map(_layout, paths))
530
+ skipped = [{"path": str(lay["path"]), "reason": lay["skip"][0], "error": lay["skip"][1]}
531
+ for lay in layouts if "skip" in lay]
532
+ groups: Dict[str, List[str]] = {}
533
+ notes: List[str] = []
534
+ for lay in layouts:
535
+ if lay.get("bare"):
536
+ if repos is None:
537
+ continue # its worktrees, when under the roots, are found on their own
538
+ trees = _worktrees(_Git(str(lay["path"]), notes))
539
+ if not trees:
540
+ notes.append(f"{lay['path']}: bare repository with no worktrees{_NOTHING}")
541
+ for t in trees:
542
+ groups.setdefault(lay["common"], []).append(t)
543
+ elif "common" in lay:
544
+ groups.setdefault(lay["common"], []).append(str(lay["path"]))
545
+ now = time.time()
546
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
547
+ results = list(pool.map(lambda c: _probe(sorted(groups[c])[0], c, kinds, stale_days, now), sorted(groups)))
548
+ results.sort(key=lambda r: r["repo"])
549
+ warnings = notes + [w for r in results for w in r["warnings"]]
550
+ skipped += [r["skipped"] for r in results if r["skipped"]]
551
+ rep: dict = {"roots": [str(r) for r in roots], "roots_source": roots_source, "repos": len(results),
552
+ "online": online}
553
+ for k in KINDS:
554
+ rep[key(k)] = []
555
+ for k in ("unpushed", "merged_gone", "stash", "dirty", "no_remote", "stale", "empty"):
556
+ if k in kinds:
557
+ rep[key(k)] = [e for r in results for e in r[key(k)]]
558
+ if "duplicate" in kinds:
559
+ rep["duplicates"] = _duplicates(results, cfg)
560
+ if online and "remote_gone" in kinds:
561
+ rep["remote_gone"] = remote_gone(results, cfg, warnings, announce_to=announce_to)
562
+ rep["unpushed"] = _sorted(rep["unpushed"], "repo", "branch")
563
+ rep["merged_gone"] = _sorted(rep["merged_gone"], "repo", "branch")
564
+ rep["stashes"] = sorted(rep["stashes"], key=lambda e: (e["repo"], _stash_n(e["ref"])))
565
+ for k in ("dirty", "no_remote", "stale", "empty"):
566
+ rep[k] = _sorted(rep[k], "repo")
567
+ rep["worktrees"] = {r["repo"]: sorted(r["worktrees"]) for r in results if r["worktrees"]}
568
+ rep["skipped"] = _sorted(skipped, "path")
569
+ rep["warnings"] = [redact(w) for w in warnings]
570
+ rep["ok"] = not any(rep[k] for k in COUNTED if k != "remote_gone") and \
571
+ not any(e["status"] != "unknown" for e in rep["remote_gone"])
572
+ return rep
573
+
574
+
575
+ def _stash_n(ref: str) -> int:
576
+ m = re.search(r"\{(\d+)\}", ref)
577
+ return int(m[1]) if m else 0
578
+
579
+
580
+ # ---------------------------------------------------------------- text
581
+
582
+ def _tilde(path: str) -> str:
583
+ from .undo import tilde
584
+ return tilde(Path(path))
585
+
586
+
587
+ def _sh(path: str) -> str:
588
+ """A tilde path quoted for the shell, the leading ~ left unquoted (as cli._sh)."""
589
+ return path if path == "~" else "~/" + shlex.quote(path[2:]) if path.startswith("~/") else shlex.quote(path)
590
+
591
+
592
+ def _fix_text(fix: str, repo: str) -> str:
593
+ return fix.replace(f"-C {shlex.quote(repo)} ", f"-C {_sh(_tilde(repo))} ", 1)
594
+
595
+
596
+ def _detail_text(text: str, repos: Sequence[str]) -> str:
597
+ for r in sorted(repos, key=len, reverse=True):
598
+ text = text.replace(r, _tilde(r))
599
+ return text
600
+
601
+
602
+ def _table(rows: List[List[str]]) -> List[str]:
603
+ """Rows of cells, every column but the last padded to its widest cell; a trailing fix as ' → fix'."""
604
+ if not rows:
605
+ return []
606
+ width = max(len(r) for r in rows)
607
+ cols = [max((len(r[i]) for r in rows if len(r) > i + 1), default=0) for i in range(width)]
608
+ lines = []
609
+ for r in rows:
610
+ cells = [c.ljust(cols[i]) if i < len(r) - 1 else c for i, c in enumerate(r)]
611
+ lines.append(" " + " ".join(cells).rstrip())
612
+ return lines
613
+
614
+
615
+ def _with_fix(line: str, fix: Optional[str], repo: str) -> str:
616
+ return f"{line} → {_fix_text(fix, repo)}" if fix else line
617
+
618
+
619
+ def _unpushed_text(e: dict) -> str:
620
+ if e["detached"]:
621
+ return f"{plural(e['commits'], 'commit')} not on any branch"
622
+ if not e["upstream"]:
623
+ return f"{plural(e['commits'], 'commit')}, no upstream"
624
+ if e["ahead"]:
625
+ return f"{e['ahead']} ahead of {e['upstream']}"
626
+ return f"{plural(e['commits'], 'commit')} not on any remote"
627
+
628
+
629
+ def _repos_in(items: List[dict]) -> int:
630
+ return len({e["repo"] for e in items})
631
+
632
+
633
+ def render(report: dict, verbose: bool, *, only: Optional[Set[str]] = None, failed: Optional[int] = None) -> str:
634
+ """The text report (spec 03 §2.3). only: the kinds --only asked for (None without --only); failed: how
635
+ many checks failed (default: one per warning)."""
636
+ rep = report
637
+ lines = [f"{plural(rep['repos'], 'repo')} under {', '.join(_tilde(r) for r in rep['roots'])}"
638
+ " (remote state as of last fetch)", ""]
639
+ body: List[str] = []
640
+
641
+ def section(title: str, rows: List[Tuple[List[str], Optional[str], str]]) -> None:
642
+ body.append(title)
643
+ for line, (_, fix, repo) in zip(_table([r[0] for r in rows]), rows):
644
+ body.append(_with_fix(line, fix, repo))
645
+
646
+ up = rep["unpushed"]
647
+ if up:
648
+ section(f"unpushed ({plural(len(up), 'branch').replace('branchs', 'branches')} in "
649
+ f"{plural(_repos_in(up), 'repo')})",
650
+ [([_tilde(e["repo"]), "(detached HEAD)" if e["detached"] else e["branch"], _unpushed_text(e)],
651
+ e["fix"], e["repo"]) for e in up])
652
+ mg = rep["merged_gone"]
653
+ if mg:
654
+ section(f"upstream gone ({plural(len(mg), 'branch').replace('branchs', 'branches')})",
655
+ [([_tilde(e["repo"]), e["branch"], "merged, safe to delete" if not e["commits"]
656
+ else f"{plural(e['commits'], 'commit')} not on any remote"], None, e["repo"]) for e in mg])
657
+ st = rep["stashes"]
658
+ if st:
659
+ section(f"stashes ({len(st)} in {plural(_repos_in(st), 'repo')})",
660
+ [([_tilde(e["repo"]), f"{e['ref']} {e['date'][:10]}", e["subject"]], None, e["repo"]) for e in st])
661
+ di = rep["dirty"]
662
+ if di:
663
+ section(f"dirty ({plural(len(di), 'repo')})",
664
+ [([_tilde(e["repo"]), f"{e['modified']} modified, {e['untracked']} untracked"], None, e["repo"])
665
+ for e in di])
666
+ nr = rep["no_remote"]
667
+ if nr:
668
+ section(f"no remote ({plural(len(nr), 'repo')})",
669
+ [([_tilde(e["repo"]), f"{plural(e['commits'], 'commit')}, never pushed anywhere"], None, e["repo"])
670
+ for e in nr])
671
+ du = rep["duplicates"]
672
+ if du:
673
+ body.append(f"duplicates ({plural(len(du), 'group')})")
674
+ rows = []
675
+ for g in du:
676
+ state = ("diverged: " + _detail_text(g["detail"], g["repos"]) if g["diverged"]
677
+ else g["detail"] if g["diverged"] is None else "same HEAD")
678
+ rows.append([g["key"] if "/" not in g["key"][:1] else _tilde(g["key"]),
679
+ " ".join(_tilde(r) for r in g["repos"]), state])
680
+ body.extend(_table(rows))
681
+ rg = [e for e in rep["remote_gone"] if verbose or e["status"] != "unknown"]
682
+ if rg:
683
+ section(f"remote gone ({plural(_repos_in(rg), 'repo')})",
684
+ [([_tilde(e["repo"]), e["url"],
685
+ f"missing via {e['via']}" if e["status"] == "missing"
686
+ else f"renamed to {e['renamed_to']}" if e["status"] == "renamed"
687
+ else "? unknown" + (f" ({e['detail']})" if e["detail"] else "")], e["fix"], e["repo"])
688
+ for e in rg])
689
+ elif not rep["online"] and only is None:
690
+ body.append("remote gone: run with --online to check remotes via gh/glab")
691
+ show_stale = verbose or (only is not None and "stale" in only)
692
+ if rep["stale"] and show_stale:
693
+ section(f"stale ({plural(len(rep['stale']), 'repo')})",
694
+ [([_tilde(e["repo"]), f"last commit {e['last_commit'][:10]}", f"{e['days']} days ago"], None,
695
+ e["repo"]) for e in rep["stale"]])
696
+ if rep["empty"] and (verbose or (only is not None and "empty" in only)):
697
+ section(f"empty ({plural(len(rep['empty']), 'repo')})", [([_tilde(e["repo"])], None, e["repo"])
698
+ for e in rep["empty"]])
699
+ if verbose and rep["worktrees"]:
700
+ section("worktrees", [([_tilde(m), " ".join(_tilde(w) for w in ws)], None, m)
701
+ for m, ws in rep["worktrees"].items()])
702
+ if verbose and rep["skipped"]:
703
+ section(f"skipped ({len(rep['skipped'])})", [([_tilde(s["path"]), s["error"]], None, s["path"])
704
+ for s in rep["skipped"]])
705
+ lines += body
706
+ if body:
707
+ lines.append("")
708
+ lines.append(_summary(rep, show_stale, len(rep["warnings"]) if failed is None else failed))
709
+ return "\n".join(lines)
710
+
711
+
712
+ def _summary(rep: dict, show_stale: bool, failed: int = 0) -> str:
713
+ from .scan import skipped as skipped_text
714
+ counted = [e for k in COUNTED if k not in ("duplicates", "remote_gone") for e in rep[k]]
715
+ counted += [e for e in rep["remote_gone"] if e["status"] != "unknown"]
716
+ repos = {e["repo"] for e in counted} | {r for g in rep["duplicates"] for r in g["repos"]}
717
+ items = len(counted) + len(rep["duplicates"])
718
+ where = ", ".join(_tilde(r) for r in rep["roots"])
719
+ first = (f"{plural(items, 'item')} in {plural(len(repos), 'repo')}" if items
720
+ else f"nothing lost found under {where} ({plural(failed, 'check')} failed)" if failed
721
+ else f"✓ nothing lost under {where}")
722
+ extra = []
723
+ if rep["stale"] and not show_stale:
724
+ extra.append(f"stale repos: {len(rep['stale'])} (-v lists them)")
725
+ if rep["skipped"]:
726
+ extra.append(skipped_text(rep["skipped"]))
727
+ return " · ".join([first] + extra)
728
+
729
+
730
+ # ---------------------------------------------------------------- command
731
+
732
+ def _config(args) -> Tuple[object, object, List[str]]:
733
+ """(full Config or None, Scan or None, warnings). A config whose other tables are broken still gives
734
+ its [scan]; a broken [scan] is a warning unless -c named the file."""
735
+ from . import config
736
+ try:
737
+ path, how = config.find(args.config, args.home)
738
+ except ConfigError:
739
+ if args.config:
740
+ raise
741
+ return None, None, []
742
+ try:
743
+ cfg = config.load_all(path, how)
744
+ return cfg, cfg.scan, []
745
+ except ConfigError:
746
+ pass
747
+ try:
748
+ return None, config.load_scan(path), []
749
+ except ConfigError as e:
750
+ if args.config:
751
+ raise
752
+ where = "scanning ~" if not args.dirs else "ignoring it"
753
+ return None, None, [f"{_tilde(str(path))}: {e}; {where}"]
754
+
755
+
756
+ def _roots(args, scan_cfg) -> Tuple[List[Path], List[str], str]:
757
+ try:
758
+ from gitpro.scan import effective_roots
759
+ except ImportError:
760
+ effective_roots = None
761
+ if effective_roots is not None:
762
+ roots, exclude, source = effective_roots(list(args.dirs), scan_cfg)
763
+ return [Path(r) for r in roots], list(exclude), source
764
+ if args.dirs:
765
+ raw, source = list(args.dirs), "args"
766
+ elif scan_cfg is not None:
767
+ raw, source = list(scan_cfg.roots), "config"
768
+ else:
769
+ raw, source = ["~"], "default"
770
+ out: List[Path] = []
771
+ for r in raw:
772
+ p = Path(r).expanduser().resolve()
773
+ if p not in out:
774
+ out.append(p)
775
+ return out, list(scan_cfg.exclude) if scan_cfg is not None else [], source
776
+
777
+
778
+ def _kinds(only: Optional[str]) -> Set[str]:
779
+ chosen = set()
780
+ for k in (only or "").split(","):
781
+ k = k.strip()
782
+ if not k:
783
+ continue
784
+ if k not in KINDS:
785
+ raise ConfigError(f"unknown kind '{k}' ({', '.join(KINDS)})")
786
+ chosen.add(k)
787
+ return chosen
788
+
789
+
790
+ def cmd_lost(args) -> int:
791
+ cfg, scan_cfg, warnings = _config(args)
792
+ only = _kinds(args.only) if args.only else None
793
+ from . import forge
794
+ online = bool(args.online) or (not args.offline and cfg is not None and cfg.forge.online)
795
+ fell_back = False
796
+ if online and not args.online and (only is None or "remote_gone" in only) and \
797
+ not (forge.available("gh") or forge.available("glab")):
798
+ # [forge] online = true is a preference, not a demand: without gh/glab, stay offline and say so.
799
+ warnings.append("[forge] online = true, but neither gh nor glab is on PATH; checking offline "
800
+ "(remote gone skipped)")
801
+ online, fell_back = False, True
802
+ kinds = set(only) if only else set(OFFLINE_KINDS) | ({"remote_gone"} if online else set())
803
+ if "remote_gone" in kinds and fell_back:
804
+ return fail("lost --only remote_gone needs gh or glab on PATH (remote check); install one", args)
805
+ if "remote_gone" in kinds and not online:
806
+ accs = forge.accounts(cfg) if cfg is not None else []
807
+ if args.json or not accs or args.offline:
808
+ return fail("lost --only remote_gone needs the network; re-run with --online", args)
809
+ return forge.no_online_error("lost", "whether each remote repo exists", accs, SENDS)
810
+ if "remote_gone" in kinds and not (forge.available("gh") or forge.available("glab")):
811
+ return fail("lost --online needs gh or glab on PATH (remote check); install one or run without --online",
812
+ args)
813
+ stale_days = args.stale_days or (scan_cfg.stale_days if scan_cfg is not None else 180)
814
+ for d in args.dirs:
815
+ if not Path(d).expanduser().is_dir():
816
+ return fail(f"{d}: no such directory", args)
817
+ if args.repo:
818
+ repos = [p.expanduser().resolve() for p in args.repo]
819
+ for given, p in zip(args.repo, repos):
820
+ if not p.is_dir():
821
+ return fail(f"{given}: no such directory" if not p.exists() else f"{given}: not a directory", args)
822
+ roots, exclude, source = repos, [], "args"
823
+ else:
824
+ repos = None
825
+ roots, exclude, source = _roots(args, scan_cfg)
826
+ rep = collect(roots, exclude, cfg, online=online, kinds=kinds, stale_days=stale_days, repos=repos,
827
+ roots_source=source, announce_to=sys.stderr if args.json else None)
828
+ failed = sum(not w.endswith(_NOTHING) for w in rep["warnings"])
829
+ rep["warnings"] = warnings + rep["warnings"]
830
+ rc = 0 if rep["ok"] else 1
831
+ if args.json:
832
+ jsonout.emit("lost", rep)
833
+ return rc
834
+ for w in rep["warnings"]:
835
+ print(f"⚠ {_detail_text(w, _paths(rep))}", file=sys.stderr)
836
+ print(render(rep, args.verbose, only=only, failed=failed))
837
+ return rc
838
+
839
+
840
+ def _paths(rep: dict) -> List[str]:
841
+ paths = {e["repo"] for k in ("unpushed", "merged_gone", "stashes", "dirty", "no_remote", "remote_gone",
842
+ "stale", "empty") for e in rep[k]}
843
+ paths |= {s["path"] for s in rep["skipped"]} | set(rep["worktrees"])
844
+ paths |= {w for ws in rep["worktrees"].values() for w in ws}
845
+ return sorted(paths)
846
+
847
+
848
+ def _positive(text: str) -> int:
849
+ try:
850
+ n = int(text)
851
+ except ValueError:
852
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
853
+ if n <= 0:
854
+ raise argparse.ArgumentTypeError("must be a positive number of days")
855
+ return n
856
+
857
+
858
+ def register(sub, common, confirm) -> None:
859
+ sp = sub.add_parser("lost", parents=[common],
860
+ help="unpushed branches, stashes, dirty trees, repos without a remote (read-only)")
861
+ sp.add_argument("dirs", nargs="*", metavar="DIR", help="where to look (default: [scan] roots, else ~)")
862
+ sp.add_argument("--repo", action="append", type=Path, metavar="PATH", help="check exactly this repo (repeatable)")
863
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
864
+ sp.add_argument("-v", "--verbose", action="store_true",
865
+ help="also list stale and empty repos, worktrees, skipped paths and unknown remotes")
866
+ only = sp.add_argument("--only", metavar="KIND[,KIND...]", help=", ".join(KINDS))
867
+ only.gitpro_values = "kinds"
868
+ net = sp.add_mutually_exclusive_group()
869
+ net.add_argument("--online", action="store_true",
870
+ help="ask GitHub/GitLab via gh/glab whether each remote still exists (sends owner/repo names)")
871
+ net.add_argument("--offline", action="store_true", help="stay offline even when [forge] online = true")
872
+ sp.add_argument("--stale-days", type=_positive, metavar="N", help="stale after N days (default: [scan] stale_days, 180)")
873
+ sp.set_defaults(func=lambda a: guarded(cmd_lost, a))
874
+ capability("lost", "lost.online")