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/resolve.py ADDED
@@ -0,0 +1,898 @@
1
+ """Expected vs effective identity for a repo: the one place that judges "is this repo right?".
2
+
3
+ expected() picks the identity the same way git's includeIf "hasconfig:remote.*.url:<pattern>" does
4
+ (render.url_patterns against the raw remote urls, git's wildmatch, later identity wins).
5
+ effective() asks git what it resolves and where each value comes from. compare() lists the problems,
6
+ each with the command that fixes it. Read-only; three git calls; no network.
7
+
8
+ Module level imports only the standard library, config and jsonout (render loads lazily), so
9
+ `gitpro.cli` can register `why` without paying for it.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import shlex
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
21
+
22
+ from . import config, jsonout
23
+ from .config import Config, ConfigError, Identity, check
24
+
25
+ MIN_GIT = (2, 36)
26
+ TIMEOUT = 30
27
+ PIN_FILE = "git-identities" # pin.FILE; in the repo's common git dir
28
+ MANAGED_GITCONFIG = ".gitconfig-identities" # plan.MANAGED_GITCONFIG, relative to home
29
+
30
+ PROBLEM_CODES = ("no_identity", "url_form", "override", "pin_stale", "global_override", "not_applied",
31
+ "key_mismatch", "sign_mismatch", "env_override", "policy")
32
+
33
+ # Hook point for other packages (14: policy). Each entry is called as
34
+ # check(report: Report) -> Iterable[Problem]
35
+ # with the report of the built-in checks (report.repo, .expected, .effective, .problems); its problems
36
+ # are appended in list order. Use code "policy" (or a code in PROBLEM_CODES). A check that raises is
37
+ # skipped with a "⚠" line on stderr; compare() never crashes because of one. Packages append to this
38
+ # list when they are imported; compare() never imports them.
39
+ EXTRA_CHECKS: List[Callable[["Report"], Iterable["Problem"]]] = []
40
+
41
+ ENV_VARS = ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL")
42
+ CONFIG_ENV = ("GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS")
43
+ # Removed from every child git's environment: a hook asking about another repo must not be redirected.
44
+ _REDIRECT_VARS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR")
45
+ _KEYS = {"user.name": "name", "user.email": "email", "user.signingkey": "signingkey",
46
+ "commit.gpgsign": "gpgsign", "core.sshcommand": "sshcommand"}
47
+ _KEYS_RE = r"^(user\.(name|email|signingkey)|commit\.gpgsign|core\.sshcommand)$"
48
+
49
+
50
+ # ---------------------------------------------------------------- data
51
+
52
+ @dataclass(frozen=True)
53
+ class Match:
54
+ identity: Optional[Identity] # None -> [default]
55
+ remote: Optional[str] # remote whose url matched ("origin"); None when nothing matched or a url was given
56
+ url: Optional[str] # the raw url that matched
57
+ pattern: Optional[str] # the url_patterns() entry that matched
58
+ rule: str # "matched origin by pattern ..." / "no identity pattern matches <url>"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Expected:
63
+ id: Optional[str]
64
+ name: Optional[str]
65
+ email: Optional[str]
66
+ ssh_key: Optional[str] # the -i path of the core.sshCommand render writes (expanded), or None
67
+ sign: bool # the commit.gpgsign render writes (git extras included)
68
+ file: Optional[str] # identity.file (expanded) or ~/.gitconfig-identities (expanded)
69
+ match: Match
70
+ signingkey: Optional[str] = None # the user.signingkey render writes when sign is true (expanded), or None
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Setting:
75
+ value: Optional[str]
76
+ origin: Optional[str] # "file:/abs/path", "command line:", "standard input:", ...
77
+ scope: Optional[str] # repo | pin | worktree | generated | global | system | command | other
78
+
79
+
80
+ UNSET = Setting(None, None, None)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Effective:
85
+ name: Setting
86
+ email: Setting
87
+ signingkey: Setting
88
+ gpgsign: Setting
89
+ sshcommand: Setting
90
+ pinned: bool
91
+ remotes: Dict[str, str] # raw urls, name -> url (only *.url, never pushurl)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Problem:
96
+ code: str
97
+ message: str
98
+ fix: Optional[str] # one shell command (or a short instruction), or None
99
+ fix_argv: Optional[Tuple[str, ...]] = None # the same fix as an argv a tool may run, or None
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Report:
104
+ repo: Path
105
+ expected: Expected
106
+ effective: Effective
107
+ problems: Tuple[Problem, ...]
108
+
109
+ def ok(self) -> bool:
110
+ return not self.problems
111
+
112
+ def to_json(self) -> dict:
113
+ e, m, f = self.expected, self.expected.match, self.effective
114
+ return {
115
+ "ok": self.ok(),
116
+ "repo": str(self.repo),
117
+ "remotes": {n: redact_url(u) for n, u in f.remotes.items()},
118
+ "expected": {"id": e.id, "name": e.name, "email": e.email, "ssh_key": e.ssh_key, "sign": e.sign,
119
+ "file": e.file, "match": {"remote": m.remote, "url": redact_url(m.url), "pattern": m.pattern,
120
+ "rule": m.rule}},
121
+ "effective": {**{k: _setting_json(getattr(f, k)) for k in _KEYS.values()}, "pinned": f.pinned},
122
+ "problems": [{"code": p.code, "message": p.message, "fix": p.fix,
123
+ "fix_argv": list(p.fix_argv) if p.fix_argv else None} for p in self.problems],
124
+ }
125
+
126
+
127
+ def _setting_json(s: Setting) -> dict:
128
+ return {"value": s.value, "origin": s.origin, "scope": s.scope}
129
+
130
+
131
+ # ---------------------------------------------------------------- git
132
+
133
+ def _child_env() -> Dict[str, str]:
134
+ return {k: v for k, v in os.environ.items() if k not in _REDIRECT_VARS}
135
+
136
+
137
+ def _git(repo: Path, *args: str, env: Optional[Mapping[str, str]] = None) -> subprocess.CompletedProcess:
138
+ try:
139
+ return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
140
+ env=_child_env() if env is None else dict(env),
141
+ stdin=subprocess.DEVNULL, timeout=TIMEOUT)
142
+ except FileNotFoundError:
143
+ raise ConfigError("git not found") from None
144
+ except subprocess.TimeoutExpired:
145
+ raise ConfigError(f"git {' '.join(args[:2])} timed out in {repo}") from None
146
+
147
+
148
+ def git_version() -> Tuple[int, int]:
149
+ try:
150
+ out = subprocess.run(["git", "--version"], capture_output=True, text=True, check=True,
151
+ timeout=TIMEOUT).stdout
152
+ except (OSError, subprocess.SubprocessError):
153
+ raise ConfigError("git not found") from None
154
+ m = re.search(r"(\d+)\.(\d+)", out)
155
+ return (int(m[1]), int(m[2])) if m else (0, 0)
156
+
157
+
158
+ def require_git() -> None:
159
+ ver = git_version()
160
+ check(ver >= MIN_GIT, f"git {ver[0]}.{ver[1]} is too old; includeIf hasconfig needs "
161
+ f"{MIN_GIT[0]}.{MIN_GIT[1]}+ (Ubuntu: ppa:git-core/ppa, macOS: brew install git)")
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class _Layout:
166
+ git_dir: Path
167
+ common_dir: Path
168
+ top: Optional[Path] # the work tree; None for a bare repo
169
+ base: Path # what git's relative "file:" origins are relative to
170
+
171
+ @property
172
+ def root(self) -> Path:
173
+ return self.top or self.git_dir
174
+
175
+
176
+ def _work_tree_of(repo: Path, git_dir: Path, common_dir: Path,
177
+ env: Optional[Mapping[str, str]] = None) -> Optional[Path]:
178
+ """The work tree when git ran inside a git dir (.git/ or .git/worktrees/<name>/); None when bare."""
179
+ if _git(repo, "rev-parse", "--is-bare-repository", env=env).stdout.strip() != "false":
180
+ return None
181
+ if git_dir != common_dir: # linked worktree: gitdir names its .git file
182
+ try:
183
+ return Path((git_dir / "gitdir").read_text(encoding="utf-8").strip()).parent
184
+ except OSError:
185
+ return None
186
+ wt = _git(repo, "config", "--get", "core.worktree", env=env).stdout.strip()
187
+ return Path(os.path.normpath(os.path.join(str(git_dir), wt))) if wt else git_dir.parent
188
+
189
+
190
+ def _layout(repo: Path, env: Optional[Mapping[str, str]] = None) -> Optional[_Layout]:
191
+ # In a bare repo, or inside a .git dir, --show-toplevel fails (exit 128) after printing the first
192
+ # two lines; use them anyway. git then does not chdir, so relative origins are relative to repo.
193
+ r = _git(repo, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-toplevel", env=env)
194
+ lines = r.stdout.splitlines()
195
+ if len(lines) < 2:
196
+ return None
197
+ git_dir, common_dir = Path(lines[0]), Path(lines[1])
198
+ if len(lines) > 2 and lines[2]:
199
+ return _Layout(git_dir, common_dir, Path(lines[2]), Path(lines[2]))
200
+ return _Layout(git_dir, common_dir, _work_tree_of(repo, git_dir, common_dir, env), Path(os.path.abspath(repo)))
201
+
202
+
203
+ def _need_layout(repo: Path, env: Optional[Mapping[str, str]] = None) -> _Layout:
204
+ lay = _layout(repo, env)
205
+ if lay is None:
206
+ raise ConfigError(f"{repo}: not a git repository")
207
+ return lay
208
+
209
+
210
+ def is_repo(path: Path) -> Optional[Path]:
211
+ """The work-tree top level holding path (the git dir for a bare repo), or None."""
212
+ lay = _layout(Path(path))
213
+ return lay.root if lay else None
214
+
215
+
216
+ def _remote_urls(repo: Path, env: Optional[Mapping[str, str]] = None) -> List[Tuple[str, str]]:
217
+ """Every (name, raw url) in config order, several per remote when it has several urls: includeIf
218
+ hasconfig:remote.*.url matches any of them (before insteadOf, never pushurl)."""
219
+ out: List[Tuple[str, str]] = []
220
+ for entry in _git(repo, "config", "-z", "--get-regexp", r"^remote\..*\.url$", env=env).stdout.split("\0"):
221
+ key, nl, value = entry.partition("\n")
222
+ if nl and key.startswith("remote.") and key.endswith(".url"):
223
+ out.append((key[len("remote."):-len(".url")], value))
224
+ return out
225
+
226
+
227
+ def _by_name(urls: List[Tuple[str, str]]) -> Dict[str, str]:
228
+ out: Dict[str, str] = {}
229
+ for name, url in urls:
230
+ out.setdefault(name, url)
231
+ return out
232
+
233
+
234
+ def remotes(repo: Path) -> Dict[str, str]:
235
+ """name -> raw url (the first url of each remote), in config order. For display; matching uses
236
+ every url of every remote, as git does."""
237
+ return _by_name(_remote_urls(repo))
238
+
239
+
240
+ # ---------------------------------------------------------------- urls
241
+
242
+ _SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://(?:([^@/]*)@)?(\[[^\]]*\]|[^/:]*)(?::(\d*))?(/.*)?$")
243
+ _SCP_RE = re.compile(r"^(?:([^@/:]+)@)?(\[[^\]]*\]|[^/:]+):(.*)$")
244
+
245
+
246
+ def parse_url(url: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
247
+ """(host, owner, repo) for display, clone and discover; never used for matching. owner is everything
248
+ between the host and the last path segment (nested groups); local paths and file:// give
249
+ (None, None, basename)."""
250
+ def split(path: str) -> Tuple[Optional[str], Optional[str]]:
251
+ parts = [p for p in path.split("/") if p]
252
+ if not parts:
253
+ return None, None
254
+ repo = parts[-1][:-4] if parts[-1].endswith(".git") else parts[-1]
255
+ return ("/".join(parts[:-1]) or None), (repo or None)
256
+
257
+ m = _SCHEME_RE.match(url)
258
+ if m:
259
+ if m[1].lower() == "file":
260
+ return (None, None, split(m[5] or "")[1])
261
+ owner, repo = split(m[5] or "")
262
+ return (m[3] or None), owner, repo
263
+ m = _SCP_RE.match(url)
264
+ if m and "/" not in url.split(":", 1)[0]:
265
+ owner, repo = split(m[3])
266
+ return m[2], owner, repo
267
+ return (None, None, split(url)[1])
268
+
269
+
270
+ _USERINFO_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://([^/]*)@")
271
+ # Query parameters whose value is a secret (case-insensitive). Keep in sync with vscode/src/exec.ts SECRET_KEYS.
272
+ SECRET_QUERY_KEYS = ("access_token", "private_token", "oauth_token", "refresh_token", "id_token", "token", "auth",
273
+ "authorization", "password", "passwd", "pwd", "secret", "client_secret", "api_key", "apikey",
274
+ "key", "sig", "signature", "x-amz-signature", "x-amz-credential", "x-amz-security-token", "jwt")
275
+ _SECRET_QUERY_RE = re.compile(r"([?&;](?:" + "|".join(re.escape(k) for k in SECRET_QUERY_KEYS) + r")=)[^&;#\s]*",
276
+ re.IGNORECASE)
277
+
278
+
279
+ _URL_IN_TEXT = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s\"'<>]+")
280
+
281
+
282
+ def redact_text(value: str) -> str:
283
+ """Redact credential-bearing URLs embedded in a diagnostic or configuration excerpt."""
284
+ return _URL_IN_TEXT.sub(lambda m: redact_url(m[0]) or m[0], value) if "://" in value else value
285
+
286
+
287
+ def redact_url(url: Optional[str]) -> Optional[str]:
288
+ """The url for display: a password in the userinfo becomes *** (scheme://user:***@host/...), and so
289
+ does a bare token in an http(s) userinfo (https://***@host/...); so does the value of a secret query
290
+ parameter (?access_token=***, SECRET_QUERY_KEYS). ssh://git@host and scp forms are unchanged. Display
291
+ only; matching always uses the real url."""
292
+ if not url:
293
+ return url
294
+ if "?" in url:
295
+ q = url.index("?")
296
+ url = url[:q] + _SECRET_QUERY_RE.sub(r"\1***", url[q:])
297
+ m = _USERINFO_RE.match(url)
298
+ if not m:
299
+ return url
300
+ user, sep, _secret = m[2].partition(":")
301
+ if sep:
302
+ info = f"{user}:***"
303
+ elif m[1].lower() in ("http", "https"):
304
+ info = "***"
305
+ else:
306
+ return url
307
+ return f"{m[1]}://{info}@{url[m.end():]}"
308
+
309
+
310
+ _POSIX_CLASSES = {"alnum": "a-zA-Z0-9", "alpha": "a-zA-Z", "blank": " \\t", "cntrl": "\\x00-\\x1f\\x7f",
311
+ "digit": "0-9", "graph": "!-~", "lower": "a-z", "print": " -~", "punct": "!-/:-@\\[-`{-~",
312
+ "space": " \\t\\n\\r\\f\\v", "upper": "A-Z", "xdigit": "0-9A-Fa-f"}
313
+
314
+
315
+ def _class(pattern: str, i: int) -> Tuple[Optional[str], int]:
316
+ """Translate the bracket expression starting at pattern[i] == "["; (None, i) when it is not closed."""
317
+ j = i + 1
318
+ negate = j < len(pattern) and pattern[j] in "!^"
319
+ if negate:
320
+ j += 1
321
+ items: List[str] = []
322
+ first = True
323
+ while j < len(pattern):
324
+ c = pattern[j]
325
+ if c == "]" and not first:
326
+ body = "".join(items) or "\\]"
327
+ # With WM_PATHNAME a bracket expression never matches "/".
328
+ return (f"[^/{body}]" if negate else f"(?!/)[{body}]"), j + 1
329
+ first = False
330
+ if c == "[" and pattern.startswith("[:", j):
331
+ end = pattern.find(":]", j + 2)
332
+ if end != -1 and pattern[j + 2:end] in _POSIX_CLASSES:
333
+ items.append(_POSIX_CLASSES[pattern[j + 2:end]])
334
+ j = end + 2
335
+ continue
336
+ if c == "\\" and j + 1 < len(pattern):
337
+ j += 1
338
+ c = pattern[j]
339
+ if j + 2 < len(pattern) and pattern[j + 1] == "-" and pattern[j + 2] != "]":
340
+ hi = pattern[j + 2]
341
+ if hi == "\\" and j + 3 < len(pattern):
342
+ hi = pattern[j + 3]
343
+ j += 1
344
+ items.append(f"{re.escape(c)}-{re.escape(hi)}")
345
+ j += 3
346
+ continue
347
+ items.append(re.escape(c))
348
+ j += 1
349
+ return None, i
350
+
351
+
352
+ _WM_CACHE: Dict[str, "re.Pattern[str]"] = {}
353
+
354
+
355
+ def _wm_regex(pattern: str) -> "re.Pattern[str]":
356
+ rx = _WM_CACHE.get(pattern)
357
+ if rx is not None:
358
+ return rx
359
+ out: List[str] = []
360
+ i, n = 0, len(pattern)
361
+ while i < n:
362
+ c = pattern[i]
363
+ if c == "*":
364
+ j = i
365
+ while j < n and pattern[j] == "*":
366
+ j += 1
367
+ whole = j - i >= 2 and (i == 0 or pattern[i - 1] == "/") and (j == n or pattern[j] == "/")
368
+ if not whole:
369
+ out.append("[^/]*") # "*", or "**" inside a component: does not cross "/"
370
+ elif j == n:
371
+ out.append(".*") # trailing "/**" or a bare "**": anything, including "/"
372
+ else:
373
+ out.append("(?:.*/)?") # "**/": zero or more leading components
374
+ j += 1
375
+ i = j
376
+ elif c == "?":
377
+ out.append("[^/]")
378
+ i += 1
379
+ elif c == "[":
380
+ cls, end = _class(pattern, i)
381
+ if cls is None:
382
+ out.append(re.escape(c))
383
+ i += 1
384
+ else:
385
+ out.append(cls)
386
+ i = end
387
+ elif c == "\\" and i + 1 < n:
388
+ out.append(re.escape(pattern[i + 1]))
389
+ i += 2
390
+ else:
391
+ out.append(re.escape(c))
392
+ i += 1
393
+ rx = re.compile("".join(out), re.S)
394
+ _WM_CACHE[pattern] = rx
395
+ return rx
396
+
397
+
398
+ def wildmatch(pattern: str, text: str) -> bool:
399
+ """git's wildmatch with WM_PATHNAME, as includeIf hasconfig:remote.*.url uses it: case-sensitive,
400
+ whole string; "*", "?" and [...] never match "/"; "**" crosses "/" only as a whole path component
401
+ ("a/**", "**/b", "a/**/b", "**"), elsewhere it is "*"."""
402
+ return _wm_regex(pattern).fullmatch(text) is not None
403
+
404
+
405
+ def _identity_match(i: Identity, urls: Sequence[Tuple[Optional[str], str]]
406
+ ) -> Optional[Tuple[Optional[str], str, str]]:
407
+ from .render import url_patterns
408
+ pats = url_patterns(i)
409
+ for name, url in urls:
410
+ for p in pats:
411
+ if wildmatch(p, url):
412
+ return name, url, p
413
+ return None
414
+
415
+
416
+ def match_url(url: str, cfg: Config) -> Tuple[Optional[Identity], Optional[str]]:
417
+ """(identity, pattern) git would include for a remote with this raw url; the later identity wins.
418
+ (None, None) when nothing matches ([default] applies)."""
419
+ found: Tuple[Optional[Identity], Optional[str]] = (None, None)
420
+ for i in cfg.identities:
421
+ m = _identity_match(i, [(None, url)])
422
+ if m:
423
+ found = (i, m[2])
424
+ return found
425
+
426
+
427
+ # ---------------------------------------------------------------- expected
428
+
429
+ def _expand(p: str) -> str:
430
+ return os.path.expanduser(p)
431
+
432
+
433
+ def _candidates(urls: List[Tuple[str, str]], remote: Optional[str],
434
+ remote_url: Optional[str] = None) -> List[Tuple[Optional[str], str]]:
435
+ if remote_url is not None:
436
+ name = remote if any(n == remote for n, _ in urls) else None
437
+ return [(name, remote_url)]
438
+ if remote is None:
439
+ return list(urls)
440
+ named = [(n, u) for n, u in urls if n == remote]
441
+ return list(named) if named else [(None, remote)] # `git push <url>` hands the hook the url, not a name
442
+
443
+
444
+ def _git_value(sections: dict, section: str, key: str) -> Optional[str]:
445
+ """The value of section.key in a render settings dict (git's case-insensitive names; for a
446
+ multi-valued key, the last value, as `git config --get` reports it)."""
447
+ def gval(v: object) -> str:
448
+ return ("true" if v else "false") if isinstance(v, bool) else str(v)
449
+ found: Optional[str] = None
450
+ for sec, body in sections.items():
451
+ if sec.lower() != section or not isinstance(body, dict):
452
+ continue
453
+ for k, v in body.items():
454
+ if k.lower() == key and not isinstance(v, dict):
455
+ v = v[-1] if isinstance(v, list) and v else v
456
+ found = None if isinstance(v, list) else gval(v)
457
+ return found
458
+
459
+
460
+ def _rendered(cfg: Config, i: Optional[Identity]) -> Tuple[Optional[str], bool, Optional[str]]:
461
+ """(ssh key, gpgsign, signingkey) as the generated files give them: the identity file (i.git over
462
+ its base) beats the managed file ([default.git] over its base), exactly as render merges them."""
463
+ from . import render
464
+
465
+ def expand(p: str) -> Path:
466
+ return Path(_expand(p))
467
+ layers = [render.default_settings(cfg.default, expand)]
468
+ if i is not None:
469
+ layers.append(render.identity_settings(i, cfg.default, expand))
470
+
471
+ def get(section: str, key: str) -> Optional[str]:
472
+ vals = [_git_value(layer, section, key) for layer in layers]
473
+ return next((v for v in reversed(vals) if v is not None), None)
474
+ sign = _bool(get("commit", "gpgsign"))
475
+ signingkey = get("user", "signingkey")
476
+ return (ssh_key_of(get("core", "sshcommand")), sign,
477
+ _expand(signingkey) if sign and signingkey else None)
478
+
479
+
480
+ def _expected_for(cfg: Config, urls: List[Tuple[Optional[str], str]]) -> Expected:
481
+ found = None
482
+ for i in cfg.identities: # every matching include applies; the later one wins
483
+ m = _identity_match(i, urls)
484
+ if m:
485
+ found = (i, m)
486
+ d = cfg.default
487
+ if found:
488
+ i, (name, url, pattern) = found
489
+ rule = f"matched {name or redact_url(url)} by pattern {pattern}"
490
+ key, sign, signingkey = _rendered(cfg, i)
491
+ return Expected(i.id, i.name, i.email, key, sign, _expand(i.file),
492
+ Match(i, name, url, pattern, rule), signingkey)
493
+ shown = ", ".join(redact_url(u) or "" for _, u in urls)
494
+ rule = f"no identity pattern matches {shown}" if urls else "no remote url, so no identity pattern can match"
495
+ key, sign, signingkey = _rendered(cfg, None)
496
+ return Expected(None, d.name, d.email, key, sign, _expand("~/" + MANAGED_GITCONFIG),
497
+ Match(None, None, None, None, rule), signingkey)
498
+
499
+
500
+ def expected(repo: Path, cfg: Config, *, remote: Optional[str] = None,
501
+ remote_url: Optional[str] = None) -> Expected:
502
+ """The identity git should apply in repo. remote= restricts matching to one remote name, or to a
503
+ url (pre-push with `git push <url>`). remote_url= judges that destination instead of the fetch URLs."""
504
+ _need_layout(Path(repo))
505
+ return _expected_for(cfg, _candidates(_remote_urls(Path(repo)), remote, remote_url))
506
+
507
+
508
+ # ---------------------------------------------------------------- effective
509
+
510
+ def _scope(git_scope: str, origin: str, lay: _Layout, generated: Dict[str, bool]) -> str:
511
+ path = origin[len("file:"):] if origin.startswith("file:") else None
512
+ if git_scope == "local":
513
+ if path and os.path.realpath(path) == os.path.realpath(lay.common_dir / PIN_FILE):
514
+ return "pin"
515
+ return "repo"
516
+ if git_scope == "global":
517
+ if path is not None:
518
+ if path not in generated:
519
+ from .render import is_generated
520
+ try:
521
+ with open(path, encoding="utf-8", errors="replace") as f:
522
+ head = f.readline() + f.readline()
523
+ generated[path] = is_generated(head)
524
+ except OSError:
525
+ generated[path] = False
526
+ if generated[path]:
527
+ return "generated"
528
+ return "global"
529
+ if git_scope in ("worktree", "system", "command"):
530
+ return git_scope
531
+ return "other"
532
+
533
+
534
+ def _absolute(origin: str, lay: _Layout) -> str:
535
+ # git prints local origins relative to the work-tree top level (bare: the git dir); make them absolute.
536
+ if origin.startswith("file:") and not origin[5:].startswith("/"):
537
+ return "file:" + os.path.normpath(os.path.join(str(lay.base), origin[5:]))
538
+ return origin
539
+
540
+
541
+ def _effective(repo: Path, env: Optional[Mapping[str, str]] = None) -> Tuple[Effective, _Layout, List[Tuple[str, str]]]:
542
+ lay = _need_layout(repo, env)
543
+ r = _git(repo, "config", "-z", "--show-scope", "--show-origin", "--get-regexp", _KEYS_RE, env=env)
544
+ if r.returncode not in (0, 1):
545
+ raise ConfigError(r.stderr.strip() or f"git config failed in {repo} ({r.returncode})")
546
+ parts = r.stdout.split("\0")
547
+ found: Dict[str, Setting] = {}
548
+ pinned = False
549
+ generated: Dict[str, bool] = {}
550
+ for k in range(0, len(parts) - 2, 3):
551
+ git_scope, origin, kv = parts[k], parts[k + 1], parts[k + 2]
552
+ key, _, value = kv.partition("\n")
553
+ if key.lower() not in _KEYS:
554
+ continue
555
+ origin = _absolute(origin, lay)
556
+ scope = _scope(git_scope, origin, lay, generated)
557
+ pinned = pinned or scope == "pin"
558
+ found[_KEYS[key.lower()]] = Setting(value, origin, scope) # git prints every definition; the last wins
559
+ urls = _remote_urls(repo, env)
560
+ eff = Effective(found.get("name", UNSET), found.get("email", UNSET), found.get("signingkey", UNSET),
561
+ found.get("gpgsign", UNSET), found.get("sshcommand", UNSET), pinned, _by_name(urls))
562
+ return eff, lay, urls
563
+
564
+
565
+ def effective(repo: Path) -> Effective:
566
+ """What git resolves in repo for user.name/email/signingkey, commit.gpgsign, core.sshcommand, and
567
+ where each value comes from (the last definition git prints wins)."""
568
+ return _effective(Path(repo))[0]
569
+
570
+
571
+ # ---------------------------------------------------------------- compare
572
+
573
+ def _bool(v: Optional[str]) -> bool:
574
+ return (v or "").strip().lower() in ("true", "yes", "on", "1")
575
+
576
+
577
+ def ssh_key_of(command: Optional[str]) -> Optional[str]:
578
+ """The -i path of an ssh command line, ~ expanded; None when there is none."""
579
+ if not command:
580
+ return None
581
+ try:
582
+ words = shlex.split(command)
583
+ except ValueError:
584
+ return None
585
+ for n, w in enumerate(words):
586
+ if w == "-i" and n + 1 < len(words):
587
+ return _expand(words[n + 1])
588
+ if w.startswith("-i") and len(w) > 2:
589
+ return _expand(w[2:])
590
+ return None
591
+
592
+
593
+ def _same_path(a: Optional[str], b: Optional[str]) -> bool:
594
+ return a is not None and b is not None and os.path.normpath(_expand(a)) == os.path.normpath(_expand(b))
595
+
596
+
597
+ def _who(e: Expected) -> str:
598
+ return f"the {e.id} identity" if e.id else "[default]"
599
+
600
+
601
+ def _q(argv: Sequence[str]) -> str:
602
+ return shlex.join(list(argv))
603
+
604
+
605
+ def _p(code: str, message: str, argv: Optional[Sequence[str]], fix: Optional[str] = None) -> Problem:
606
+ return Problem(code, message, fix if fix is not None else (_q(argv) if argv else None),
607
+ tuple(argv) if argv else None)
608
+
609
+
610
+ def _url_fixes(url: str, cfg: Config) -> List[Tuple[str, str, Identity, str]]:
611
+ """[(fixed url, reason, identity, pattern)] for the three repairs, in order: userinfo removed,
612
+ missing user added, port removed. First repair that makes some identity match wins."""
613
+ tries: List[Tuple[str, str]] = []
614
+ m = re.match(r"^(https?://)[^@/]+@(.*)$", url)
615
+ if m:
616
+ tries.append((m[1] + m[2], "no user in the URL"))
617
+ users = list(dict.fromkeys(i.ssh_user for i in cfg.identities)) or ["git"]
618
+ m = re.match(r"^ssh://([^@/]+)(/.*)$", url)
619
+ if m:
620
+ tries += [(f"ssh://{u}@{m[1]}{m[2]}", f"user {u}@ in the URL") for u in users]
621
+ elif "://" not in url:
622
+ m = _SCP_RE.match(url)
623
+ if m and m[1] is None and "/" not in url.split(":", 1)[0]:
624
+ tries += [(f"{u}@{url}", f"user {u}@ in the URL") for u in users]
625
+ m = re.match(r"^((?:ssh|https?)://[^/]*?):\d+(/.*)$", url)
626
+ if m:
627
+ tries.append((m[1] + m[2], "no port in the URL"))
628
+ for fixed, reason in tries:
629
+ i, pat = match_url(fixed, cfg)
630
+ if i is not None:
631
+ return [(fixed, reason, i, pat)]
632
+ return []
633
+
634
+
635
+ def _env_problem(exp: Expected, eff: Effective, env: Mapping[str, str]) -> Optional[Problem]:
636
+ want = {"NAME": exp.name, "EMAIL": exp.email}
637
+ names = [v for v in ENV_VARS if env.get(v) and env.get(v) != want[v.rsplit("_", 1)[1]]]
638
+ cmd = [k for k, s in (("user.name", eff.name), ("user.email", eff.email))
639
+ if s.scope == "command" and s.value != want[k.split(".")[1].upper()]]
640
+ if not names and not cmd:
641
+ return None
642
+ parts = []
643
+ if names:
644
+ parts.append(" and ".join(f"{v}={env[v]}" for v in names) + " in the environment")
645
+ if cmd:
646
+ parts.append(" and ".join(cmd) + " from the command line (git -c or GIT_CONFIG_*)")
647
+ names += [v for v in CONFIG_ENV if env.get(v)]
648
+ msg = f"{'; '.join(parts)} override{'s' if len(names) + len(cmd) == 1 else ''} {_who(exp)}"
649
+ return Problem("env_override", msg, ("unset " + " ".join(names)) if names else None, None)
650
+
651
+
652
+ def _home_has_block(home: Path) -> bool:
653
+ from .render import has_block
654
+ try:
655
+ return has_block((home / ".gitconfig").read_text(encoding="utf-8", errors="replace"))
656
+ except OSError:
657
+ return False
658
+
659
+
660
+ def compare(repo: Path, cfg: Config, *, remote: Optional[str] = None,
661
+ remote_url: Optional[str] = None, env: Optional[Mapping[str, str]] = None,
662
+ home: Optional[Path] = None, git_env: Optional[Mapping[str, str]] = None,
663
+ extra_checks: bool = True) -> Report:
664
+ """Expected vs effective, with every problem that applies (order: PROBLEM_CODES, then EXTRA_CHECKS).
665
+ remote= is a remote name or a url; remote_url= overrides its fetch URLs with the push destination.
666
+ env= is the environment whose GIT_AUTHOR_*/GIT_COMMITTER_* are
667
+ judged (default os.environ; a git hook passes one without GIT_AUTHOR_*, which git always exports
668
+ there). home= is where ~/.gitconfig is looked for (default: the real home; `--home` passes its own).
669
+ By default child Git strips repository-redirection variables. git_env= supplies a caller-owned,
670
+ already scoped child environment without modifying os.environ. extra_checks=False lets strict
671
+ operation evaluators run policy checks explicitly rather than inherit fail-open hook behavior."""
672
+ repo = Path(repo)
673
+ eff, lay, urls = _effective(repo, git_env)
674
+ top = lay.root
675
+ candidates = _candidates(urls, remote, remote_url)
676
+ exp = _expected_for(cfg, candidates)
677
+ env = os.environ if env is None else env
678
+ who = _who(exp)
679
+ out: List[Problem] = []
680
+ first_url = candidates[0][1] if candidates else None
681
+
682
+ if exp.email is None:
683
+ out.append(_p("no_identity", f"no identity for {redact_url(first_url) or top}",
684
+ ["gitpro", "scan", "--suggest", "--repo", str(top)],
685
+ f"add an identity: gitpro scan --suggest --repo {shlex.quote(str(top))}"))
686
+
687
+ if exp.match.identity is None:
688
+ for name, url in candidates:
689
+ fixes = _url_fixes(url, cfg)
690
+ if fixes:
691
+ fixed, reason, i, pat = fixes[0]
692
+ argv = ["git", "-C", str(top), "remote", "set-url", name, fixed] \
693
+ if name and remote_url is None else None
694
+ need = pat.replace("**", "…")
695
+ out.append(_p("url_form", f"{name or 'the remote'} is {redact_url(url)}; the {i.id} patterns need {need} "
696
+ f"({reason})", argv))
697
+ break
698
+
699
+ for key, s, want in (("name", eff.name, exp.name), ("email", eff.email, exp.email)):
700
+ if s.scope in ("repo", "worktree") and want is not None and s.value != want:
701
+ where = "the repo's .git/config" if s.scope == "repo" else "this worktree's config.worktree"
702
+ argv = ["git", "-C", str(top), "config"] + (["--worktree"] if s.scope == "worktree" else []) \
703
+ + ["--unset", f"user.{key}"]
704
+ out.append(_p("override", f"user.{key} is set in {where} and overrides {who}", argv))
705
+
706
+ stale = [(k, s) for k, s, want in (("name", eff.name, exp.name), ("email", eff.email, exp.email))
707
+ if s.scope == "pin" and want is not None and s.value != want]
708
+ if stale:
709
+ what = ", ".join(f"user.{k} {s.value}" for k, s in stale)
710
+ out.append(_p("pin_stale", f"the repo's pin ({PIN_FILE}) still says {what}; {who} is "
711
+ f"{exp.name} <{exp.email}>", ["gitpro", "pin", "-y", str(top)]))
712
+
713
+ beats = [(k, s) for k, s, want in (("name", eff.name, exp.name), ("email", eff.email, exp.email))
714
+ if s.scope in ("global", "system", "other") and want is not None and s.value != want]
715
+ if beats:
716
+ k, s = beats[0]
717
+ out.append(_p("global_override", f"user.{k} comes from {_shown_origin(s, top)} ({s.scope}) and beats "
718
+ f"{who}; the generated include must come last", ["gitpro", "apply"]))
719
+
720
+ home = Path.home() if home is None else Path(home)
721
+ old = [k for k, s, want in (("name", eff.name, exp.name), ("email", eff.email, exp.email))
722
+ if s.scope == "generated" and want is not None and s.value != want]
723
+ if exp.file and not os.path.exists(exp.file):
724
+ out.append(_p("not_applied", f"{_tilde(exp.file)} does not exist; the config has not been applied",
725
+ ["gitpro", "apply"]))
726
+ elif not _home_has_block(home):
727
+ out.append(_p("not_applied", "~/.gitconfig has no gitpro block; the config has not been applied",
728
+ ["gitpro", "apply"]))
729
+ elif old:
730
+ out.append(_p("not_applied", f"the generated files give user.{old[0]} "
731
+ f"{getattr(eff, old[0]).value}; they are older than the config",
732
+ ["gitpro", "apply"]))
733
+
734
+ def fix_for(s: Setting, key: str) -> List[str]:
735
+ if s.scope in ("repo", "worktree"):
736
+ return ["git", "-C", str(top), "config"] + (["--worktree"] if s.scope == "worktree" else []) \
737
+ + ["--unset", key]
738
+ if s.scope == "global" and (s.origin or "").startswith("file:"):
739
+ return ["git", "config", "--file", s.origin[5:], "--unset", key]
740
+ return ["gitpro", "apply"]
741
+
742
+ def own(s: Setting) -> str:
743
+ # a value from the user's own config, outside gitpro's generated files
744
+ if s.scope == "global":
745
+ return f" (from your own {_shown_origin(s, top)}, outside gitpro's block)"
746
+ if s.scope in ("system", "other"):
747
+ return f" (from {_shown_origin(s, top)}, {s.scope} config outside gitpro)"
748
+ return ""
749
+
750
+ if exp.ssh_key:
751
+ got = ssh_key_of(eff.sshcommand.value)
752
+ if not _same_path(got, exp.ssh_key):
753
+ msg = (f"core.sshcommand uses {_tilde(got)}{own(eff.sshcommand)}; {who} uses {_tilde(exp.ssh_key)}"
754
+ if got else f"core.sshcommand is not set; {who} uses {_tilde(exp.ssh_key)}")
755
+ out.append(_p("key_mismatch", msg, fix_for(eff.sshcommand, "core.sshCommand")))
756
+
757
+ sign_now = _bool(eff.gpgsign.value)
758
+ if sign_now != exp.sign:
759
+ out.append(_p("sign_mismatch", f"commit.gpgsign is {'true' if sign_now else 'false'}{own(eff.gpgsign)}; {who} "
760
+ f"{'signs' if exp.sign else 'does not sign'} commits",
761
+ fix_for(eff.gpgsign, "commit.gpgsign")))
762
+ elif exp.sign and exp.signingkey and not _same_path(eff.signingkey.value, exp.signingkey):
763
+ out.append(_p("sign_mismatch", f"user.signingkey is {_tilde(eff.signingkey.value) or '(unset)'}"
764
+ f"{own(eff.signingkey)}; {who} "
765
+ f"signs with {_tilde(exp.signingkey)}",
766
+ fix_for(eff.signingkey, "user.signingkey")))
767
+
768
+ envp = _env_problem(exp, eff, env)
769
+ if envp:
770
+ out.append(envp)
771
+
772
+ base = Report(top, exp, eff, tuple(out))
773
+ for extra in list(EXTRA_CHECKS) if extra_checks else ():
774
+ try:
775
+ out.extend(extra(base) or ())
776
+ except Exception as e: # noqa: BLE001 - another package's check must never break compare
777
+ name = getattr(extra, "__qualname__", None) or repr(extra)
778
+ print(f"⚠ skipped check {getattr(extra, '__module__', '?')}.{name}: {e}", file=sys.stderr)
779
+ return Report(top, exp, eff, tuple(out))
780
+
781
+
782
+ def own_emails(cfg: Config) -> Set[str]:
783
+ """Every email in [default] and the identities."""
784
+ return {e for e in [cfg.default.email, *(i.email for i in cfg.identities)] if e}
785
+
786
+
787
+ def identity_conflicts(expected_email: Optional[str], own: Iterable[str], author: Optional[str],
788
+ committer: Optional[str]) -> Tuple[bool, bool]:
789
+ """Shared committer/author rule; a colleague's authorship is not an identity override."""
790
+ if expected_email is None:
791
+ return False, False
792
+ want = expected_email.casefold()
793
+ return (committer is not None and committer.casefold() != want,
794
+ author is not None and author.casefold() != want and
795
+ author.casefold() in {email.casefold() for email in own})
796
+
797
+
798
+ # ---------------------------------------------------------------- display
799
+
800
+ def _tilde(path: Optional[str]) -> str:
801
+ if not path:
802
+ return ""
803
+ home = str(Path.home())
804
+ if path == home:
805
+ return "~"
806
+ return "~/" + path[len(home) + 1:] if path.startswith(home + "/") else path
807
+
808
+
809
+ def _shown_origin(s: Setting, top: Path) -> str:
810
+ o = s.origin or ""
811
+ if not o.startswith("file:"):
812
+ return o.rstrip(":") or "?"
813
+ path = o[5:]
814
+ if path.startswith(str(top) + "/"):
815
+ return path[len(str(top)) + 1:]
816
+ return _tilde(path)
817
+
818
+
819
+ def _shown_fix(p: Problem) -> str:
820
+ if not p.fix_argv:
821
+ return p.fix or ""
822
+ home = str(Path.home())
823
+
824
+ def word(a: str) -> str:
825
+ if a.startswith(home + "/"):
826
+ return "~/" + shlex.quote(a[len(home) + 1:])
827
+ return shlex.quote(a)
828
+ # keep a leading explanation ("add an identity: ") that the argv itself does not carry
829
+ cut = (p.fix or "").find(p.fix_argv[0])
830
+ prefix = p.fix[:cut] if cut > 0 else ""
831
+ return prefix + " ".join(word(a) for a in p.fix_argv)
832
+
833
+
834
+ def render_text(rep: Report, cfg: Config) -> str:
835
+ e, f, top = rep.expected, rep.effective, rep.repo
836
+ lines = [_tilde(str(top))]
837
+ if cfg.path:
838
+ lines.append(f" config {_tilde(str(cfg.path))}" + (f" ({cfg.how})" if cfg.how else ""))
839
+ rems = [f"{n} {redact_url(u)}" for n, u in f.remotes.items()] or ["(none)"]
840
+ lines.append(f" remotes {rems[0]}")
841
+ lines += [f" {r}" for r in rems[1:]]
842
+ label = e.id or "(default)"
843
+ if e.email:
844
+ lines.append(f" expected {label} {e.name or '(no name)'} <{e.email}>")
845
+ else:
846
+ lines.append(f" expected {label} no name/email in [default]; git will refuse to commit")
847
+ lines.append(f" {e.match.rule}")
848
+ if f.email.value or f.name.value:
849
+ src = f.email if f.email.value else f.name
850
+ where = _shown_origin(src, top) + (" (pin)" if src.scope == "pin" else "")
851
+ lines.append(f" effective {f.name.value or '(no name)'} <{f.email.value or 'no email'}> [{where}]"
852
+ + (" · pinned" if f.pinned else ""))
853
+ else:
854
+ lines.append(" effective (unset)")
855
+ key = ssh_key_of(f.sshcommand.value)
856
+ if key:
857
+ lines.append(f" ssh key {_tilde(key)} via core.sshcommand")
858
+ elif e.ssh_key:
859
+ lines.append(" ssh key (none set)")
860
+ if f.email.value or f.name.value:
861
+ on = _bool(f.gpgsign.value)
862
+ lines.append(" signing " + (f"on {_tilde(_expand(f.signingkey.value or ''))}".rstrip() if on else "off"))
863
+ if rep.ok():
864
+ lines.append("✓ identity matches")
865
+ for p in rep.problems:
866
+ lines.append(f"✗ {p.message}")
867
+ if p.fix or p.fix_argv:
868
+ extra = f" (or: gitpro scan --fix --repo {_tilde(str(top))})" if p.code == "override" else ""
869
+ lines.append(f"→ {_shown_fix(p)}{extra}")
870
+ return "\n".join(lines) + "\n"
871
+
872
+
873
+ # ---------------------------------------------------------------- `gitpro why`
874
+
875
+ def register(sub, common, confirm) -> None:
876
+ from .args import capability
877
+ sp = sub.add_parser("why", parents=[common],
878
+ help="explain which identity a repo uses, where each value comes from, and how to fix it")
879
+ sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
880
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
881
+ sp.set_defaults(func=cmd_why)
882
+ capability("why", "why.config")
883
+
884
+
885
+ def cmd_why(args) -> int:
886
+ require_git()
887
+ home = Path(args.home).expanduser().resolve()
888
+ path, how = config.find(args.config, home)
889
+ cfg = config.load_all(path, how)
890
+ rep = compare(Path(args.dir), cfg, home=home)
891
+ if getattr(args, "json", False):
892
+ data = rep.to_json()
893
+ data.pop("ok")
894
+ jsonout.emit("why", {"repo": data.pop("repo"), "config": {"path": str(cfg.path), "how": cfg.how}, **data},
895
+ ok=rep.ok())
896
+ else:
897
+ print(render_text(rep, cfg), end="")
898
+ return 0 if rep.ok() else 1