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/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
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")
|