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/scan.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Find the repos under some directories, report the identity git uses in each, remove repo-level overrides.
|
|
2
|
+
|
|
3
|
+
A user.name or user.email in a repo's own .git/config wins over every identity gitpro writes, so a
|
|
4
|
+
forgotten one keeps committing with an old address. scan shows them next to what git would use
|
|
5
|
+
without them; fix removes them.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import glob
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from . import undo
|
|
21
|
+
from .config import HOST_RE, OWNER_RE, ConfigError, Scan
|
|
22
|
+
from .pin import FILE as PIN_FILE, git
|
|
23
|
+
|
|
24
|
+
KEYS = ("user.name", "user.email")
|
|
25
|
+
SKIP = {"node_modules"} # and every hidden directory
|
|
26
|
+
_GLOB = re.compile(r"[*?[]") # where an exclude pattern's literal prefix ends
|
|
27
|
+
_USER = r"^user\.(name|email)$"
|
|
28
|
+
# scp-like (git@host:owner/x), ssh://user@host:port/owner/x, https://host/owner/x
|
|
29
|
+
_URL = re.compile(r"^(?:[a-z][a-z0-9+.-]*://)?(?:[^@/]+@)?([^/:]+)(?::\d+(?=/))?[:/]([^/]+)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Repo:
|
|
34
|
+
path: Path
|
|
35
|
+
remote: str # origin's URL, else the first remote's; "" without remotes
|
|
36
|
+
now: dict[str, tuple[str, str]] # key -> (value, origin) that git resolves
|
|
37
|
+
local: dict[str, str] # key -> value set in the repo's own config file
|
|
38
|
+
beneath: dict[str, str] # key -> value git would resolve without those
|
|
39
|
+
|
|
40
|
+
def source(self, key: str = "user.email") -> str:
|
|
41
|
+
"""Where the value git uses comes from: 'repo', 'pin', a file path, or '' when unset."""
|
|
42
|
+
if key not in self.now:
|
|
43
|
+
return ""
|
|
44
|
+
if key in self.local:
|
|
45
|
+
return "repo"
|
|
46
|
+
path = Path(self.now[key][1][len("file:"):])
|
|
47
|
+
if path.is_absolute():
|
|
48
|
+
return str(path)
|
|
49
|
+
return "pin" if path.name == PIN_FILE else str(self.path / path)
|
|
50
|
+
|
|
51
|
+
def after_fix(self) -> dict[str, str]:
|
|
52
|
+
"""name and email once fix removed what it can (a setting nothing else provides stays)."""
|
|
53
|
+
return {k: self.beneath.get(k, self.now[k][0]) for k in KEYS if k in self.now}
|
|
54
|
+
|
|
55
|
+
def using(self) -> dict[str, str]:
|
|
56
|
+
return {k: self.now[k][0] for k in KEYS if k in self.now}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find(roots, *, exclude=(), max_depth: int | None = None, excluded: set | None = None) -> list[Path]:
|
|
60
|
+
"""Every directory holding a .git (repo, worktree or submodule), nested ones included.
|
|
61
|
+
|
|
62
|
+
Hidden directories and node_modules are always skipped. exclude: patterns as in [scan] exclude
|
|
63
|
+
(see excluded_by); a directory one matches is not entered, and is added to `excluded` when given.
|
|
64
|
+
A root that is itself excluded is skipped. max_depth: how many levels below each root to look
|
|
65
|
+
(None: no limit)."""
|
|
66
|
+
pats = _patterns(exclude)
|
|
67
|
+
found = set()
|
|
68
|
+
for root in roots:
|
|
69
|
+
root = Path(root)
|
|
70
|
+
if pats and _match(str(root), pats):
|
|
71
|
+
continue
|
|
72
|
+
base = len(root.parts)
|
|
73
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
74
|
+
if ".git" in dirnames or ".git" in filenames:
|
|
75
|
+
found.add(Path(dirpath))
|
|
76
|
+
if max_depth is not None and len(Path(dirpath).parts) - base >= max_depth:
|
|
77
|
+
dirnames[:] = []
|
|
78
|
+
continue
|
|
79
|
+
keep = []
|
|
80
|
+
for d in dirnames:
|
|
81
|
+
if d.startswith(".") or d in SKIP:
|
|
82
|
+
continue
|
|
83
|
+
full = os.path.join(dirpath, d)
|
|
84
|
+
if pats and _match(full, pats):
|
|
85
|
+
if excluded is not None:
|
|
86
|
+
excluded.add(Path(full))
|
|
87
|
+
continue
|
|
88
|
+
keep.append(d)
|
|
89
|
+
dirnames[:] = keep
|
|
90
|
+
return sorted(found)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalise(pattern: str, home: str) -> str:
|
|
94
|
+
"""A [scan] exclude pattern as an fnmatch pattern over absolute, resolved paths: ~/ is the home
|
|
95
|
+
directory, a relative pattern matches at any depth (**/ in front), ** is fnmatch's * (which
|
|
96
|
+
crosses /). An absolute pattern's part before its first glob character is resolved (roots are,
|
|
97
|
+
so ~/work/old still matches when ~/work is a symlink) and escaped (a [ in a path is literal)."""
|
|
98
|
+
p = pattern.rstrip("/") or "/"
|
|
99
|
+
if p == "~" or p.startswith("~/"):
|
|
100
|
+
lit, p = home, p[1:] # the home directory is literal, whatever it contains
|
|
101
|
+
elif p.startswith("/"):
|
|
102
|
+
lit = ""
|
|
103
|
+
else:
|
|
104
|
+
return ("**/" + p).replace("**", "*")
|
|
105
|
+
m = _GLOB.search(p)
|
|
106
|
+
cut = len(p) if m is None else p.rfind("/", 0, m.start())
|
|
107
|
+
head, tail = os.path.realpath(lit + p[:cut] or "/"), p[cut:]
|
|
108
|
+
if head == "/" and tail:
|
|
109
|
+
head = ""
|
|
110
|
+
return glob.escape(head) + tail.replace("**", "*")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _patterns(exclude) -> list[tuple[str, str]]:
|
|
114
|
+
home = str(Path.home())
|
|
115
|
+
return [(pat, _normalise(pat, home)) for pat in exclude]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _match(path: str, pats: list[tuple[str, str]]) -> str | None:
|
|
119
|
+
return next((pat for pat, norm in pats if fnmatch.fnmatchcase(path, norm)), None)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def excluded_by(path: Path, exclude) -> str | None:
|
|
123
|
+
"""The first exclude pattern (as written) that matches the absolute directory path, else None."""
|
|
124
|
+
return _match(str(path), _patterns(exclude))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def effective_roots(args_roots: list[str], cfg_scan: Scan | None) -> tuple[list[Path], list[str], str]:
|
|
128
|
+
"""-> (roots, exclude, source): the command line's directories ("args"), else [scan] roots from a
|
|
129
|
+
config that loaded ("config"), else the home directory ("default"). exclude comes from the config
|
|
130
|
+
in every case; --repo never consults this. Roots are absolute and resolved, duplicates dropped."""
|
|
131
|
+
if args_roots:
|
|
132
|
+
roots, source = args_roots, "args"
|
|
133
|
+
elif cfg_scan is not None and cfg_scan.roots: # roots = [] counts as unset (scanroots warns)
|
|
134
|
+
roots, source = list(cfg_scan.roots), "config"
|
|
135
|
+
else:
|
|
136
|
+
roots, source = ["~"], "default"
|
|
137
|
+
out: list[Path] = []
|
|
138
|
+
for r in roots:
|
|
139
|
+
p = Path(r).expanduser().resolve()
|
|
140
|
+
if p not in out:
|
|
141
|
+
out.append(p)
|
|
142
|
+
return out, list(cfg_scan.exclude) if cfg_scan is not None else [], source
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _entries(repo: str, *scope: str) -> list[tuple[str, str, str]]:
|
|
146
|
+
"""(origin, key, value) for user.name and user.email, in the order git reads them (the last one wins)."""
|
|
147
|
+
toks = git(repo, "config", *scope, "-z", "--show-origin", "--get-regexp", _USER, ok=(0, 1)).split("\0")
|
|
148
|
+
out = []
|
|
149
|
+
for origin, entry in zip(toks[0::2], toks[1::2]):
|
|
150
|
+
key, _, value = entry.partition("\n")
|
|
151
|
+
out.append((origin, key, value))
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def inspect(path: Path) -> Repo:
|
|
156
|
+
r = str(path)
|
|
157
|
+
urls = dict(line.split(" ", 1) for line in
|
|
158
|
+
git(r, "config", "--get-regexp", r"^remote\..*\.url$", ok=(0, 1)).splitlines())
|
|
159
|
+
own = _entries(r, "--local")
|
|
160
|
+
own_origins = {origin for origin, _, _ in own}
|
|
161
|
+
now, beneath = {}, {}
|
|
162
|
+
for origin, key, value in _entries(r):
|
|
163
|
+
now[key] = (value, origin)
|
|
164
|
+
if origin not in own_origins:
|
|
165
|
+
beneath[key] = value
|
|
166
|
+
return Repo(path, urls.get("remote.origin.url") or next(iter(urls.values()), ""), now,
|
|
167
|
+
{key: value for _, key, value in own}, beneath)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _owner_name(uid: int) -> str:
|
|
171
|
+
try:
|
|
172
|
+
import pwd
|
|
173
|
+
return pwd.getpwuid(uid).pw_name
|
|
174
|
+
except (ImportError, KeyError):
|
|
175
|
+
return f"uid {uid}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def skip_reason(path: Path, error: str) -> tuple[str, str]:
|
|
179
|
+
"""(reason, detail) for a repo git refused to read. The reason groups repos in the summary
|
|
180
|
+
("owned by root"); the detail says what is wrong with this one. git's own message is often
|
|
181
|
+
misleading here: for another user's repo it says "--local can only be used inside a git repository"."""
|
|
182
|
+
dotgit = path / ".git"
|
|
183
|
+
if dotgit.is_file():
|
|
184
|
+
try:
|
|
185
|
+
line = dotgit.read_text(errors="replace").splitlines()[0]
|
|
186
|
+
except (OSError, IndexError):
|
|
187
|
+
line = ""
|
|
188
|
+
if line.startswith("gitdir:"):
|
|
189
|
+
target = line[len("gitdir:"):].strip()
|
|
190
|
+
if not (path / target).exists():
|
|
191
|
+
return "with a broken .git link", f"broken .git: points to missing {target}"
|
|
192
|
+
getuid = getattr(os, "getuid", None) # not on Windows
|
|
193
|
+
if getuid:
|
|
194
|
+
try:
|
|
195
|
+
uid = (dotgit if dotgit.exists() else path).stat().st_uid
|
|
196
|
+
except OSError:
|
|
197
|
+
uid = getuid()
|
|
198
|
+
if uid != getuid():
|
|
199
|
+
name = _owner_name(uid)
|
|
200
|
+
return (f"owned by {name}",
|
|
201
|
+
f"owned by {name}: git ignores other users' repos unless listed in safe.directory")
|
|
202
|
+
return "git can't read", error
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def inspect_all(paths: list[Path]) -> tuple[list[Repo], list[tuple[Path, str, str]]]:
|
|
206
|
+
"""(repos, [(path, reason, detail) for the ones git refused to read, e.g. owned by another user])."""
|
|
207
|
+
repos, failed = [], []
|
|
208
|
+
for p in paths:
|
|
209
|
+
try:
|
|
210
|
+
repos.append(inspect(p))
|
|
211
|
+
except ConfigError as e:
|
|
212
|
+
failed.append((p, *skip_reason(p, str(e).splitlines()[0] if str(e) else "git failed")))
|
|
213
|
+
return repos, failed
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def skipped(bad: list[dict]) -> str:
|
|
217
|
+
"""'30 skipped: 26 owned by root, 4 with a broken .git link' for report()["unreadable"]."""
|
|
218
|
+
if not bad:
|
|
219
|
+
return ""
|
|
220
|
+
counts = Counter(b["reason"] for b in bad).most_common()
|
|
221
|
+
return f"{len(bad)} skipped: " + ", ".join(f"{n} {why}" for why, n in counts)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def host_owner(url: str) -> tuple[str, str] | None:
|
|
225
|
+
"""(host, owner) of a remote URL, parsed from its redacted form: a userinfo secret never ends up in either."""
|
|
226
|
+
from .resolve import redact_url
|
|
227
|
+
m = _URL.match(redact_url(url) or url)
|
|
228
|
+
return (m[1], m[2]) if m else None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def owner(url: str) -> str:
|
|
232
|
+
"""'host/owner' of a remote URL; the URL itself (redacted) when it has no host (a local path)."""
|
|
233
|
+
if not url:
|
|
234
|
+
return "no remote"
|
|
235
|
+
from .resolve import redact_url
|
|
236
|
+
url = redact_url(url) or url
|
|
237
|
+
ho = host_owner(url)
|
|
238
|
+
return "/".join(ho) if ho else url
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def derive_id(owner: str, host: str, taken: set) -> str:
|
|
242
|
+
"""The [[identity]] id for an owner: lowercase, other characters as '-', then '-<first host label>'
|
|
243
|
+
until it is not in taken. taken is not changed; the caller adds the id it uses."""
|
|
244
|
+
ident = re.sub(r"[^A-Za-z0-9._-]", "-", owner).lower()
|
|
245
|
+
while ident in taken:
|
|
246
|
+
ident += "-" + host.split(".")[0]
|
|
247
|
+
return ident
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def suggest(repos: list[Repo]) -> tuple[list[dict], list[Repo]]:
|
|
251
|
+
"""[[identity]] blocks that keep the name and email fix would change, and the repos no identity can match.
|
|
252
|
+
|
|
253
|
+
An identity matches by host and owner, so each block says how many other repos of those owners it changes.
|
|
254
|
+
"""
|
|
255
|
+
wanted: dict[tuple[str, str, str], Counter] = {}
|
|
256
|
+
unmatched = []
|
|
257
|
+
for r in repos:
|
|
258
|
+
now = r.using()
|
|
259
|
+
if not r.local or r.after_fix() == now:
|
|
260
|
+
continue
|
|
261
|
+
ho = host_owner(r.remote)
|
|
262
|
+
if not (ho and HOST_RE.match(ho[0]) and OWNER_RE.match(ho[1]) and len(now) == 2):
|
|
263
|
+
unmatched.append(r)
|
|
264
|
+
continue
|
|
265
|
+
wanted.setdefault((ho[0], now["user.name"], now["user.email"]), Counter())[ho[1]] += 1
|
|
266
|
+
claimed = Counter((host, o) for (host, _, _), owners in wanted.items() for o in owners)
|
|
267
|
+
blocks, ids = [], set()
|
|
268
|
+
for (host, name, email), owners in sorted(wanted.items()):
|
|
269
|
+
names = sorted(owners, key=lambda o: (-owners[o], o))
|
|
270
|
+
others = sum(1 for r in repos if host_owner(r.remote) in {(host, o) for o in names}
|
|
271
|
+
and r.after_fix().get("user.email") != email and r.using().get("user.email") != email)
|
|
272
|
+
ident = derive_id(names[0], host, ids)
|
|
273
|
+
ids.add(ident)
|
|
274
|
+
repos_note = ", ".join(f"{o} {owners[o]}" for o in names)
|
|
275
|
+
blocks.append({"id": ident, "name": name, "email": email, "host": host, "owners": names, "changes_others": others,
|
|
276
|
+
"toml": "\n".join([
|
|
277
|
+
"[[identity]]",
|
|
278
|
+
f"id = {json.dumps(ident)}",
|
|
279
|
+
f"name = {json.dumps(name)}",
|
|
280
|
+
f"email = {json.dumps(email)}",
|
|
281
|
+
f"host = {json.dumps(host)}",
|
|
282
|
+
f"owners = {json.dumps(names)} # repos with this setting: {repos_note}",
|
|
283
|
+
*([f"# also changes {others} other repo(s) of these owners to this email"] if others else []),
|
|
284
|
+
*(f"# {o} is in another block too: an identity matches by owner, so keep only one"
|
|
285
|
+
for o in names if claimed[(host, o)] > 1),
|
|
286
|
+
])})
|
|
287
|
+
return blocks, unmatched
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def who(ident: dict[str, str]) -> str:
|
|
291
|
+
"""'name <email>' of a {'user.name', 'user.email'} dict; '(no email)' without an email."""
|
|
292
|
+
email = ident.get("user.email")
|
|
293
|
+
return f"{ident.get('user.name', '')} <{email}>".strip() if email else "(no email)"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def short_names(paths: list[Path]) -> dict[Path, str]:
|
|
297
|
+
"""The fewest trailing path parts that tell the paths apart: 'mcp/ai-collab', 'LLM/ai-collab'."""
|
|
298
|
+
out = {}
|
|
299
|
+
for p in paths:
|
|
300
|
+
n = 1
|
|
301
|
+
while n < len(p.parts) and sum(q.parts[-n:] == p.parts[-n:] for q in paths) > 1:
|
|
302
|
+
n += 1
|
|
303
|
+
out[p] = "/".join(p.parts[-n:])
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def report(roots: list[Path], repos: list[Repo], failed: list[tuple[Path, str, str]],
|
|
308
|
+
label: Callable[[str], str]) -> dict:
|
|
309
|
+
"""Everything scan shows, as plain data (also what --json prints). label names a source() for people."""
|
|
310
|
+
groups: dict[tuple[str, str, str], Counter] = {}
|
|
311
|
+
for r in repos:
|
|
312
|
+
now = r.using()
|
|
313
|
+
groups.setdefault((now.get("user.name", ""), now.get("user.email", ""), label(r.source())),
|
|
314
|
+
Counter())[owner(r.remote)] += 1
|
|
315
|
+
over = [r for r in repos if r.local]
|
|
316
|
+
short = short_names([r.path for r in over])
|
|
317
|
+
blocks, unmatched = suggest(repos)
|
|
318
|
+
pair = lambda d: {"name": d.get("user.name", ""), "email": d.get("user.email", "")} # noqa: E731
|
|
319
|
+
from .resolve import redact_url
|
|
320
|
+
return {
|
|
321
|
+
"roots": [str(p) for p in roots],
|
|
322
|
+
"repos": len(repos),
|
|
323
|
+
"unreadable": [{"path": str(p), "reason": why, "error": detail} for p, why, detail in failed],
|
|
324
|
+
"identities": [
|
|
325
|
+
{"name": n, "email": e, "source": src, "repos": sum(c.values()),
|
|
326
|
+
"owners": dict(sorted(c.items(), key=lambda kv: (-kv[1], kv[0])))}
|
|
327
|
+
for (n, e, src), c in sorted(groups.items(), key=lambda kv: (kv[0][2] == "repo", -sum(kv[1].values())))],
|
|
328
|
+
"overrides": [
|
|
329
|
+
{"path": str(r.path), "short": short[r.path], "remote": redact_url(r.remote) or "", "owner": owner(r.remote),
|
|
330
|
+
"now": pair(r.using()), "after": pair(r.after_fix()),
|
|
331
|
+
"settings": [{"key": k, "value": v, "without": r.beneath.get(k)} for k, v in r.local.items()],
|
|
332
|
+
"fixable": [k for k in r.local if k in r.beneath]}
|
|
333
|
+
for r in over],
|
|
334
|
+
"suggestions": blocks,
|
|
335
|
+
"unmatched": [str(r.path) for r in unmatched],
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def fix(repos: list[Repo], state: Path, prog: str = "gitpro") -> tuple[list[tuple[Repo, str]], Path | None]:
|
|
340
|
+
"""Remove every override something else can replace; ([(repo, key) removed], script that puts them back)."""
|
|
341
|
+
pairs = [(r, k) for r in repos for k in r.local if k in r.beneath]
|
|
342
|
+
if not pairs:
|
|
343
|
+
return [], None
|
|
344
|
+
# Written first, so an interrupted fix can still be undone.
|
|
345
|
+
script = undo.write("scan --fix", [f"git -C {shlex.quote(str(r.path))} config {k} {shlex.quote(r.local[k])}"
|
|
346
|
+
for r, k in pairs], what="removed repo-local user settings", state=state)
|
|
347
|
+
for r, k in pairs:
|
|
348
|
+
remove(r, k)
|
|
349
|
+
return pairs, script
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def remove(repo: Repo, key: str) -> None:
|
|
353
|
+
"""Remove key from the repo's own config, and the [user] section if that leaves it empty."""
|
|
354
|
+
r = str(repo.path)
|
|
355
|
+
git(r, "config", "--local", "--unset-all", key)
|
|
356
|
+
if not git(r, "config", "--local", "--get-regexp", r"^user\.", ok=(0, 1)):
|
|
357
|
+
# Older git leaves the emptied [user] header behind; newer git already dropped it (exit 128).
|
|
358
|
+
git(r, "config", "--local", "--remove-section", "user", ok=(0, 128))
|
gitpro/scanroots.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""scan -c/--config and --roots: where scan looks, from the command line or the config's [scan] table.
|
|
2
|
+
|
|
3
|
+
Roots: DIR... on the command line, else [scan] roots, else ~. [scan] exclude applies to both, never to
|
|
4
|
+
--repo. gitpro never edits the config: with roots on the command line it prints the line to add.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import config, jsonout, undo
|
|
15
|
+
|
|
16
|
+
_ALWAYS = {"**/node_modules", "node_modules"} # patterns every walk applies anyway
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extend(subs) -> None:
|
|
20
|
+
from .cli import capability, extend_command
|
|
21
|
+
extend_command(subs, "scan", add_flags, handler)
|
|
22
|
+
capability("scan.roots")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_flags(p: argparse.ArgumentParser) -> None:
|
|
26
|
+
p.add_argument("-c", "--config", type=Path, metavar="PATH", help="config file (default: see gitpro -h)")
|
|
27
|
+
p.add_argument("--home", type=Path, help=argparse.SUPPRESS) # accepted, ignored (labels use $HOME)
|
|
28
|
+
p.add_argument("--roots", action="store_true", help="print the directories scan looks in, and what it skips")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _warn(msg: str) -> None:
|
|
32
|
+
print(f"⚠ {msg}", file=sys.stderr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def scan_config(explicit: Path | None, *, has_args: bool) -> "tuple[config.Scan | None, Path | None]":
|
|
36
|
+
"""(Scan or None, config path or None). Lenient: only [scan] matters; a bad [scan] table or TOML
|
|
37
|
+
prints one warning and counts as no config. A config that cannot be found is no config."""
|
|
38
|
+
home = Path.home()
|
|
39
|
+
try:
|
|
40
|
+
path, _ = config.find(explicit, home)
|
|
41
|
+
except config.ConfigError:
|
|
42
|
+
return None, None
|
|
43
|
+
try:
|
|
44
|
+
return config.load_scan(path), path
|
|
45
|
+
except config.ConfigError as e:
|
|
46
|
+
msg = str(e)
|
|
47
|
+
msg = msg[len(f"{path}: "):] if msg.startswith(f"{path}: ") else msg
|
|
48
|
+
_warn(f"{undo.tilde(path)}: {msg}; " + ("its [scan] table is ignored" if has_args else "scanning ~"))
|
|
49
|
+
return None, path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def handler(args) -> int | None:
|
|
53
|
+
"""--roots prints and exits; otherwise sets args.roots_effective, exclude, roots_source and
|
|
54
|
+
roots_hint for cmd_scan, or exits 2 when no root is left to scan. --repo: as before."""
|
|
55
|
+
from . import scan
|
|
56
|
+
from .args import fail
|
|
57
|
+
if args.repo and not args.roots:
|
|
58
|
+
return None
|
|
59
|
+
cfg_scan, path = scan_config(args.config, has_args=bool(args.dirs))
|
|
60
|
+
if cfg_scan is not None and not cfg_scan.roots and not args.dirs and path is not None:
|
|
61
|
+
_warn(f"{undo.tilde(path)}: [scan] roots is empty; scanning ~")
|
|
62
|
+
written = [str(d) for d in args.dirs]
|
|
63
|
+
roots, exclude, source = scan.effective_roots(written, cfg_scan)
|
|
64
|
+
if args.roots:
|
|
65
|
+
if source != "args":
|
|
66
|
+
written = list(cfg_scan.roots) if source == "config" and cfg_scan is not None else ["~"]
|
|
67
|
+
return print_roots(args, roots, exclude, source, path if cfg_scan is not None else None,
|
|
68
|
+
as_written(written))
|
|
69
|
+
existing = [r for r in roots if r.is_dir()]
|
|
70
|
+
if not existing:
|
|
71
|
+
return fail(f"no scan root exists (looked at: {', '.join(undo.tilde(r) for r in roots)})", args)
|
|
72
|
+
for r in roots:
|
|
73
|
+
if r not in existing:
|
|
74
|
+
_warn(f"root does not exist: {undo.tilde(r)}")
|
|
75
|
+
usable = []
|
|
76
|
+
for r in existing:
|
|
77
|
+
pat = scan.excluded_by(r, exclude)
|
|
78
|
+
if pat:
|
|
79
|
+
_warn(f"root {undo.tilde(r)} is excluded by '{pat}'; skipped")
|
|
80
|
+
else:
|
|
81
|
+
usable.append(r)
|
|
82
|
+
if not usable:
|
|
83
|
+
return fail("every scan root is excluded by [scan] exclude", args)
|
|
84
|
+
args.roots_effective, args.exclude, args.roots_source = usable, exclude, source
|
|
85
|
+
if source == "args" and path is not None and path.is_file():
|
|
86
|
+
args.roots_hint = roots_hint(usable, cfg_scan, path)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def roots_hint(usable: list[Path], cfg_scan, path: Path) -> str | None:
|
|
91
|
+
"""The footer for roots given on the command line: the [scan] roots line that keeps them. Only
|
|
92
|
+
roots [scan] would accept; none when there are none, or they are what the config has already."""
|
|
93
|
+
from . import scan
|
|
94
|
+
keep = [r for r in usable if config.scan_root_error(undo.tilde(r)) is None]
|
|
95
|
+
if not keep:
|
|
96
|
+
return None
|
|
97
|
+
if cfg_scan is not None and cfg_scan.roots:
|
|
98
|
+
configured, _, _ = scan.effective_roots([], cfg_scan)
|
|
99
|
+
if set(configured) == set(keep):
|
|
100
|
+
return None
|
|
101
|
+
return f"to keep these roots: add to {undo.tilde(path)}: [scan] roots = {toml_list(keep)}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def as_written(written: list[str]) -> dict:
|
|
105
|
+
"""{resolved root: the first way it was written}, to show ~/work rather than where it points."""
|
|
106
|
+
out: dict = {}
|
|
107
|
+
for w in written:
|
|
108
|
+
out.setdefault(Path(w).expanduser().resolve(), w)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def toml_list(roots) -> str:
|
|
113
|
+
"""["~", "/mnt/d/work"]: roots as a TOML array, home shown as ~ (what [scan] roots accepts)."""
|
|
114
|
+
return json.dumps([undo.tilde(r) for r in roots], ensure_ascii=False).replace("\x7f", "\\u007f")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _label(root: Path, written: str | None) -> str:
|
|
118
|
+
"""~/work as written, and where it really is when that differs: ~/work → /mnt/data/work."""
|
|
119
|
+
if written is None:
|
|
120
|
+
return undo.tilde(root)
|
|
121
|
+
plain = Path(written).expanduser()
|
|
122
|
+
same = plain.is_absolute() and Path(os.path.normpath(plain)) == root
|
|
123
|
+
return written if same else f"{written} → {undo.tilde(root)}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def print_roots(args, roots, exclude, source: str, path: Path | None, written: dict | None = None) -> int:
|
|
127
|
+
"""`scan --roots`: the effective roots (as written, resolved when different) and exclude
|
|
128
|
+
patterns, text or --json (roots resolved; written: as given)."""
|
|
129
|
+
written = written or {}
|
|
130
|
+
missing = [r for r in roots if not r.is_dir()]
|
|
131
|
+
if getattr(args, "json", False):
|
|
132
|
+
jsonout.emit("scan", {"roots": [str(r) for r in roots], "exclude": list(exclude), "source": source,
|
|
133
|
+
"config": str(path) if path else None, "missing": [str(r) for r in missing],
|
|
134
|
+
"written": [written.get(r, str(r)) for r in roots]})
|
|
135
|
+
return 0
|
|
136
|
+
where = {"config": f"from {undo.tilde(path)}" if path else "from config", "args": "from command line",
|
|
137
|
+
"default": "default"}[source]
|
|
138
|
+
lines = [f"roots ({where}):"]
|
|
139
|
+
lines += [f" {_label(r, written.get(r))}" + (" (missing)" if r in missing else "") for r in roots]
|
|
140
|
+
lines.append("exclude:")
|
|
141
|
+
lines += [f" {e}" + (" (always)" if e in _ALWAYS else "") for e in exclude]
|
|
142
|
+
if not _ALWAYS & set(exclude):
|
|
143
|
+
lines.append(" **/node_modules (always)")
|
|
144
|
+
lines.append(" **/.* (always: hidden directories)")
|
|
145
|
+
print("\n".join(lines))
|
|
146
|
+
return 0
|