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/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
|