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/config.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Find, load and validate the identities TOML. Reads only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import MISSING, dataclass, field, fields, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
import tomli as tomllib
|
|
16
|
+
|
|
17
|
+
_toml = tomllib # the one TOML parser (tomllib, or tomli before 3.11)
|
|
18
|
+
|
|
19
|
+
ENV_VAR = "GITPRO_CONFIG"
|
|
20
|
+
USER_CONFIG = ".config/gitpro.toml" # relative to home
|
|
21
|
+
|
|
22
|
+
# Leading alphanumeric everywhere so no value can be parsed as an ssh/git option.
|
|
23
|
+
_LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
|
|
24
|
+
HOST_RE = re.compile(rf"^{_LABEL}(?:\.{_LABEL})*$")
|
|
25
|
+
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
26
|
+
USER_RE = ID_RE
|
|
27
|
+
OWNER_RE = re.compile(r"^[A-Za-z0-9*][A-Za-z0-9._*-]*(?:/[A-Za-z0-9*][A-Za-z0-9._*-]*)*$")
|
|
28
|
+
PATH_RE = re.compile(r"^(?:~/|/)[A-Za-z0-9._/@+-]+$")
|
|
29
|
+
EMAIL_RE = re.compile(r'^[^@\s<>"]+@[^@\s<>"]+$')
|
|
30
|
+
CONTROL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
|
31
|
+
SCAN_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") # [scan] roots: tab included
|
|
32
|
+
# Reserved for documentation (RFC 2606): never real, so a value left over from the example.
|
|
33
|
+
PLACEHOLDER_RE = re.compile(r"(?:^|\.)(?:example|example\.(?:com|net|org))$", re.I)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(cond: object, msg: str) -> None:
|
|
41
|
+
if not cond:
|
|
42
|
+
raise ConfigError(msg)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Strings(tuple):
|
|
46
|
+
"""A config list: a tuple (hashable, immutable) that still equals a list with the same items,
|
|
47
|
+
so code and tests written when these fields were lists keep working."""
|
|
48
|
+
__slots__ = ()
|
|
49
|
+
|
|
50
|
+
def __eq__(self, other: object) -> bool:
|
|
51
|
+
if isinstance(other, list):
|
|
52
|
+
return list(self) == other
|
|
53
|
+
return tuple.__eq__(self, other)
|
|
54
|
+
|
|
55
|
+
def __ne__(self, other: object) -> bool:
|
|
56
|
+
eq = self.__eq__(other)
|
|
57
|
+
return eq if eq is NotImplemented else not eq
|
|
58
|
+
|
|
59
|
+
__hash__ = tuple.__hash__
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _tuples(obj: object, *names: str) -> None:
|
|
63
|
+
"""Turn list values of these fields into Strings (frozen-safe, from __post_init__)."""
|
|
64
|
+
for n in names:
|
|
65
|
+
v = getattr(obj, n)
|
|
66
|
+
if isinstance(v, (list, tuple)) and not isinstance(v, Strings):
|
|
67
|
+
object.__setattr__(obj, n, Strings(v))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Trailers:
|
|
72
|
+
co_authored_by: Optional[str] = None # "allow" | "forbid" | "require" | None = inherit
|
|
73
|
+
forbid_emails: Optional[Tuple[str, ...]] = None # globs
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
_tuples(self, "forbid_emails")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Default:
|
|
81
|
+
name: Optional[str] = None
|
|
82
|
+
email: Optional[str] = None
|
|
83
|
+
ssh_key: Optional[str] = None
|
|
84
|
+
ssh_hosts: Tuple[str, ...] = ()
|
|
85
|
+
sign: bool = False
|
|
86
|
+
git: dict = field(default_factory=dict, hash=False) # the one mutable field; nobody mutates it
|
|
87
|
+
gh_account: Optional[str] = None
|
|
88
|
+
glab_account: Optional[str] = None
|
|
89
|
+
clone_dir: Optional[str] = None
|
|
90
|
+
trailers: Trailers = Trailers()
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
_tuples(self, "ssh_hosts")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Identity:
|
|
98
|
+
id: str
|
|
99
|
+
name: str
|
|
100
|
+
email: str
|
|
101
|
+
host: str
|
|
102
|
+
owners: Tuple[str, ...] = ()
|
|
103
|
+
ssh_key: Optional[str] = None
|
|
104
|
+
ssh_alias: Optional[str] = None
|
|
105
|
+
ssh_user: str = "git"
|
|
106
|
+
ssh_port: Optional[int] = None
|
|
107
|
+
rewrite: bool = True
|
|
108
|
+
rewrite_https: bool = False
|
|
109
|
+
sign: Optional[bool] = None # None: inherit [default].sign
|
|
110
|
+
file: Optional[str] = None
|
|
111
|
+
git: dict = field(default_factory=dict, hash=False)
|
|
112
|
+
gh_account: Optional[str] = None
|
|
113
|
+
glab_account: Optional[str] = None
|
|
114
|
+
clone_dir: Optional[str] = None
|
|
115
|
+
policy: Optional[str] = None
|
|
116
|
+
trailers: Trailers = Trailers()
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
_tuples(self, "owners")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Scan:
|
|
124
|
+
roots: Tuple[str, ...] = ("~",)
|
|
125
|
+
exclude: Tuple[str, ...] = ()
|
|
126
|
+
stale_days: int = 180
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
_tuples(self, "roots", "exclude")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Guard:
|
|
134
|
+
hooks: bool = False
|
|
135
|
+
on_mismatch: str = "block" # "block" | "warn"
|
|
136
|
+
chain_to: str = ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Clone:
|
|
141
|
+
dir: Optional[str] = None
|
|
142
|
+
layout: str = "{host}/{owner}/{repo}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Forge:
|
|
147
|
+
online: bool = False
|
|
148
|
+
similar: bool = False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Agents:
|
|
153
|
+
hook: str = "block" # "block" | "warn" | "off"
|
|
154
|
+
bot_emails: Tuple[str, ...] = ("*[bot]@users.noreply.github.com", "noreply@anthropic.com")
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
_tuples(self, "bot_emails")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Policy:
|
|
162
|
+
files: Tuple[str, ...] = ()
|
|
163
|
+
|
|
164
|
+
def __post_init__(self) -> None:
|
|
165
|
+
_tuples(self, "files")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class Config:
|
|
170
|
+
"""Everything in the TOML. Config(default, identities) wraps the old (Default, list[Identity]) pair."""
|
|
171
|
+
default: Default
|
|
172
|
+
identities: Tuple[Identity, ...]
|
|
173
|
+
scan: Scan = Scan()
|
|
174
|
+
guard: Guard = Guard()
|
|
175
|
+
clone: Clone = Clone()
|
|
176
|
+
forge: Forge = Forge()
|
|
177
|
+
agents: Agents = Agents()
|
|
178
|
+
policy: Policy = Policy()
|
|
179
|
+
path: Optional[Path] = None
|
|
180
|
+
how: str = ""
|
|
181
|
+
|
|
182
|
+
def __post_init__(self) -> None:
|
|
183
|
+
if not isinstance(self.identities, tuple):
|
|
184
|
+
object.__setattr__(self, "identities", tuple(self.identities))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def signs(i: Identity, d: Default) -> bool:
|
|
188
|
+
return d.sign if i.sign is None else i.sign
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def find(explicit: Path | None, home: Path) -> tuple[Path, str]:
|
|
192
|
+
"""Return (config path, where it was found)."""
|
|
193
|
+
if explicit:
|
|
194
|
+
return explicit.expanduser(), "--config"
|
|
195
|
+
if os.environ.get(ENV_VAR):
|
|
196
|
+
return Path(os.environ[ENV_VAR]).expanduser(), f"${ENV_VAR}"
|
|
197
|
+
if (home / USER_CONFIG).is_file():
|
|
198
|
+
return home / USER_CONFIG, "user config"
|
|
199
|
+
raise ConfigError(f"no config found (looked at -c, ${ENV_VAR}, ~/{USER_CONFIG}); "
|
|
200
|
+
"create one with: gitpro init")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------- validation
|
|
204
|
+
|
|
205
|
+
def _no_control_chars(value: object, where: str) -> None:
|
|
206
|
+
if isinstance(value, str):
|
|
207
|
+
check(not CONTROL_RE.search(value), f"{where}: control characters not allowed")
|
|
208
|
+
elif isinstance(value, dict):
|
|
209
|
+
for k, v in value.items():
|
|
210
|
+
_no_control_chars(k, where)
|
|
211
|
+
_no_control_chars(v, f"{where}.{k}")
|
|
212
|
+
elif isinstance(value, list):
|
|
213
|
+
for v in value:
|
|
214
|
+
_no_control_chars(v, where)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build(cls, table: object, where: str, nested: Tuple[str, ...] = ()):
|
|
218
|
+
"""A frozen `cls` from a TOML table: unknown keys and missing required keys are errors.
|
|
219
|
+
Keys in `nested` are already-built objects (trailers) and skip the control-char walk."""
|
|
220
|
+
check(isinstance(table, dict), f"{where}: expected a table")
|
|
221
|
+
_no_control_chars({k: v for k, v in table.items() if k not in nested}, where)
|
|
222
|
+
known = [f.name for f in fields(cls)]
|
|
223
|
+
unknown = sorted(set(table) - set(known))
|
|
224
|
+
check(not unknown, f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {', '.join(known)})")
|
|
225
|
+
missing = [f.name for f in fields(cls)
|
|
226
|
+
if f.default is MISSING and f.default_factory is MISSING and f.name not in table]
|
|
227
|
+
check(not missing, f"{where}: missing {', '.join(missing)}")
|
|
228
|
+
return cls(**table)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _build_nested(cls, table: object, where: str):
|
|
232
|
+
"""A nested table such as [default.trailers]; same rules as _build."""
|
|
233
|
+
return _build(cls, table, where)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _check_path(p: object, where: str) -> None:
|
|
237
|
+
if p is not None:
|
|
238
|
+
check(isinstance(p, str) and PATH_RE.match(p) and ".." not in p.split("/"),
|
|
239
|
+
f"{where}: must start with ~/ or /, use only [A-Za-z0-9._/@+-], no '..'")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _check_host(h: object, where: str) -> None:
|
|
243
|
+
check(isinstance(h, str) and HOST_RE.match(h), f"{where}: bad host {h!r}")
|
|
244
|
+
check(not PLACEHOLDER_RE.search(h), f"{where}: {h} is a placeholder from the example; use your real host")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _check_person(obj: Default | Identity, where: str, required: bool) -> None:
|
|
248
|
+
if not required and obj.name is None and obj.email is None:
|
|
249
|
+
return
|
|
250
|
+
check(isinstance(obj.name, str) and obj.name.strip() and not re.search(r"[<>]", obj.name),
|
|
251
|
+
f"{where}: bad or missing name")
|
|
252
|
+
check(isinstance(obj.email, str) and EMAIL_RE.match(obj.email), f"{where}: bad or missing email")
|
|
253
|
+
check(not PLACEHOLDER_RE.search(obj.email.rsplit("@", 1)[1]),
|
|
254
|
+
f"{where}: {obj.email} is a placeholder from the example; use your real email")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _check_bool(v: object, where: str, optional: bool = False) -> None:
|
|
258
|
+
check(isinstance(v, bool) or (optional and v is None), f"{where}: must be true or false")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _dupes(values) -> set:
|
|
262
|
+
seen, out = set(), set()
|
|
263
|
+
for v in values:
|
|
264
|
+
(out if v in seen else seen).add(v)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _check_list(v: object, where: str, what: str = "a list") -> None:
|
|
269
|
+
check(isinstance(v, tuple), f"{where} must be {what}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _check_strings(v: object, where: str) -> None:
|
|
273
|
+
"""A list of glob strings (control characters were already refused by _build)."""
|
|
274
|
+
check(isinstance(v, tuple) and all(isinstance(x, str) for x in v), f"{where} must be a list of strings")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _check_dir(p: object, where: str, key: str) -> None:
|
|
278
|
+
"""PATH_RE paths with the 3.2 message: `<where>: <key> must start with ~/ or /`."""
|
|
279
|
+
if p is not None:
|
|
280
|
+
check(isinstance(p, str) and PATH_RE.match(p) and ".." not in p.split("/"),
|
|
281
|
+
f"{where}: {key} must start with ~/ or /, use only [A-Za-z0-9._/@+-], no '..'")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _check_account(v: object, where: str, key: str) -> None:
|
|
285
|
+
if v is not None:
|
|
286
|
+
check(isinstance(v, str) and ID_RE.match(v), f"{where}: {key} must match [A-Za-z0-9][A-Za-z0-9._-]*")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_enum(v: object, allowed: Tuple[str, ...], where: str, key: str) -> None:
|
|
290
|
+
if len(allowed) == 2:
|
|
291
|
+
words = f'"{allowed[0]}" or "{allowed[1]}"'
|
|
292
|
+
else:
|
|
293
|
+
words = ", ".join(f'"{a}"' for a in allowed[:-1]) + f' or "{allowed[-1]}"'
|
|
294
|
+
check(v in allowed, f"{where}: {key} must be {words}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _with_trailers(table: object, where: str) -> object:
|
|
298
|
+
"""Copy of an identity/default table with its `trailers` sub-table built and validated;
|
|
299
|
+
`where` names the sub-table ("[default.trailers]", "identity 'x'.trailers")."""
|
|
300
|
+
if not isinstance(table, dict) or "trailers" not in table:
|
|
301
|
+
return table
|
|
302
|
+
t = _build_nested(Trailers, table["trailers"], where)
|
|
303
|
+
if t.co_authored_by is not None:
|
|
304
|
+
_check_enum(t.co_authored_by, ("allow", "forbid", "require"), where, "co_authored_by")
|
|
305
|
+
if t.forbid_emails is not None:
|
|
306
|
+
_check_strings(t.forbid_emails, f"{where}: forbid_emails")
|
|
307
|
+
return dict(table, trailers=t)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _check_extras(obj: "Default | Identity", where: str) -> None:
|
|
311
|
+
"""The keys package 01 added to [default] and [[identity]]."""
|
|
312
|
+
_check_account(obj.gh_account, where, "gh_account")
|
|
313
|
+
_check_account(obj.glab_account, where, "glab_account")
|
|
314
|
+
_check_dir(obj.clone_dir, where, "clone_dir")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
LAYOUT_RE = re.compile(r"^(?:\{(?:host|owner|repo|id)\}|[A-Za-z0-9._/-])+$")
|
|
318
|
+
TOP_LEVEL = ("agents", "clone", "default", "forge", "guard", "identity", "policy", "scan")
|
|
319
|
+
HOOKS_DIR = ".config/gitpro/hooks" # gitpro's own global hooks dir, relative to home (06)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _expand(p: str, home: Path) -> Path:
|
|
323
|
+
return home / p[2:] if p.startswith("~/") else Path(p)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def scan_root_error(r: object) -> str | None:
|
|
327
|
+
"""Why r cannot be a [scan] roots entry, else None. Roots are only walked, never written into a
|
|
328
|
+
gitconfig or ssh config, so unlike PATH_RE any character but a control character is allowed
|
|
329
|
+
(spaces, non-ASCII, WSL's /mnt/c/Users/First Last, /)."""
|
|
330
|
+
if not isinstance(r, str):
|
|
331
|
+
return "roots must be strings"
|
|
332
|
+
shown = json.dumps(r, ensure_ascii=False).replace("\x7f", "\\u007f")
|
|
333
|
+
if SCAN_CONTROL_RE.search(r):
|
|
334
|
+
return f"root {shown} contains a control character"
|
|
335
|
+
if not (r == "~" or r.startswith(("~/", "/"))):
|
|
336
|
+
return f"roots must start with ~/ or / (or be ~): {shown}"
|
|
337
|
+
if ".." in r.split("/"):
|
|
338
|
+
return f"root {shown} must not contain a .. segment"
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _scan(table: object) -> Scan:
|
|
343
|
+
w = "[scan]"
|
|
344
|
+
if isinstance(table, dict) and "roots" in table:
|
|
345
|
+
check(isinstance(table["roots"], list), f"{w}: roots must be a list")
|
|
346
|
+
s = _build(Scan, table, w)
|
|
347
|
+
_check_list(s.roots, f"{w}: roots")
|
|
348
|
+
for r in s.roots:
|
|
349
|
+
err = scan_root_error(r)
|
|
350
|
+
check(err is None, f"{w}: {err}")
|
|
351
|
+
_check_strings(s.exclude, f"{w}: exclude")
|
|
352
|
+
check(type(s.stale_days) is int and s.stale_days > 0, f"{w}: stale_days must be a positive integer")
|
|
353
|
+
return s
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _guard(table: object, home: Path) -> Guard:
|
|
357
|
+
w = "[guard]"
|
|
358
|
+
g = _build(Guard, table, w)
|
|
359
|
+
_check_bool(g.hooks, f"{w}.hooks")
|
|
360
|
+
_check_enum(g.on_mismatch, ("block", "warn"), w, "on_mismatch")
|
|
361
|
+
check(isinstance(g.chain_to, str), f"{w}: chain_to must start with ~/ or /")
|
|
362
|
+
if g.chain_to:
|
|
363
|
+
check(PATH_RE.match(g.chain_to) and ".." not in g.chain_to.split("/"),
|
|
364
|
+
f"{w}: chain_to must start with ~/ or /")
|
|
365
|
+
own = os.path.realpath(home / HOOKS_DIR)
|
|
366
|
+
check(os.path.realpath(_expand(g.chain_to, home)) != own,
|
|
367
|
+
f"{w}: chain_to must not be gitpro's own hooks dir (~/{HOOKS_DIR})")
|
|
368
|
+
return g
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _clone(table: object) -> Clone:
|
|
372
|
+
w = "[clone]"
|
|
373
|
+
c = _build(Clone, table, w)
|
|
374
|
+
_check_dir(c.dir, w, "dir")
|
|
375
|
+
lay = c.layout
|
|
376
|
+
check(isinstance(lay, str) and LAYOUT_RE.match(lay) and not lay.startswith("/")
|
|
377
|
+
and ".." not in lay.split("/") and "" not in lay.split("/"),
|
|
378
|
+
f"{w}: layout may use only {{host}} {{owner}} {{repo}} {{id}} and [A-Za-z0-9._/-]")
|
|
379
|
+
return c
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _forge(table: object) -> Forge:
|
|
383
|
+
f = _build(Forge, table, "[forge]")
|
|
384
|
+
_check_bool(f.online, "[forge].online")
|
|
385
|
+
_check_bool(f.similar, "[forge].similar")
|
|
386
|
+
return f
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _agents(table: object) -> Agents:
|
|
390
|
+
a = _build(Agents, table, "[agents]")
|
|
391
|
+
_check_enum(a.hook, ("block", "warn", "off"), "[agents]", "hook")
|
|
392
|
+
_check_strings(a.bot_emails, "[agents]: bot_emails")
|
|
393
|
+
return a
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _policy(table: object) -> Policy:
|
|
397
|
+
p = _build(Policy, table, "[policy]")
|
|
398
|
+
_check_list(p.files, "[policy]: files")
|
|
399
|
+
for f in p.files:
|
|
400
|
+
_check_dir(f, "[policy]", "files")
|
|
401
|
+
return p
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _read(path: Path) -> dict:
|
|
405
|
+
"""Permission check + TOML parse; the shared first step of load_all and load_scan."""
|
|
406
|
+
try:
|
|
407
|
+
st = path.stat()
|
|
408
|
+
raw = path.read_text()
|
|
409
|
+
except FileNotFoundError:
|
|
410
|
+
raise ConfigError(f"{path}: not found") from None
|
|
411
|
+
# The config decides what commands git runs (sshCommand, credential helpers).
|
|
412
|
+
check(st.st_uid == os.getuid() and not st.st_mode & 0o022,
|
|
413
|
+
f"{path}: must be owned by you and not group/world-writable (chmod 600 {path})")
|
|
414
|
+
return _load_toml(raw, path)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _load_toml(text: str, path: object = "config") -> dict:
|
|
418
|
+
try:
|
|
419
|
+
return _toml.loads(text)
|
|
420
|
+
except _toml.TOMLDecodeError as e:
|
|
421
|
+
raise ConfigError(f"{path}: {e}") from None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def load_all(path: Path, how: str = "") -> Config:
|
|
425
|
+
"""Load and validate every table; the Config other packages read."""
|
|
426
|
+
return _parse_all(_read(path), path, how)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def loads(text: str, path: Path, how: str = "") -> Config:
|
|
430
|
+
"""Validate proposed configuration without writing it to disk."""
|
|
431
|
+
return _parse_all(_load_toml(text, path), path, how)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_all(data: dict, path: Path, how: str) -> Config:
|
|
435
|
+
home = Path.home()
|
|
436
|
+
|
|
437
|
+
unknown = sorted(set(data) - set(TOP_LEVEL))
|
|
438
|
+
check(not unknown, f"{path}: unknown key(s) {', '.join(unknown)} (allowed: {', '.join(TOP_LEVEL)})")
|
|
439
|
+
check("default" in data, f"{path}: missing [default]")
|
|
440
|
+
check(isinstance(data.get("identity", []), list), f"{path}: write identities as [[identity]]")
|
|
441
|
+
|
|
442
|
+
d = _build(Default, _with_trailers(data["default"], "[default.trailers]"), "default",
|
|
443
|
+
nested=("trailers",))
|
|
444
|
+
_check_person(d, "default", required=False)
|
|
445
|
+
_check_path(d.ssh_key, "default.ssh_key")
|
|
446
|
+
_check_bool(d.sign, "default.sign")
|
|
447
|
+
check(isinstance(d.git, dict), "default.git: must be a table")
|
|
448
|
+
check(isinstance(d.ssh_hosts, tuple), "default.ssh_hosts: must be a list")
|
|
449
|
+
check(not d.ssh_hosts or d.ssh_key, "default: ssh_hosts needs ssh_key")
|
|
450
|
+
check(not d.sign or (d.ssh_key and d.email), "default: sign needs ssh_key and email")
|
|
451
|
+
for h in d.ssh_hosts:
|
|
452
|
+
_check_host(h, "default.ssh_hosts")
|
|
453
|
+
_check_extras(d, "[default]")
|
|
454
|
+
|
|
455
|
+
ids = []
|
|
456
|
+
for n, tbl in enumerate(data.get("identity", [])):
|
|
457
|
+
tid = tbl.get("id") if isinstance(tbl, dict) else None
|
|
458
|
+
tw = f"identity {tid!r}" if isinstance(tid, str) and ID_RE.match(tid) else f"identity[{n}]"
|
|
459
|
+
i = _build(Identity, _with_trailers(tbl, f"{tw}.trailers"), f"identity[{n}]", nested=("trailers",))
|
|
460
|
+
check(isinstance(i.id, str) and ID_RE.match(i.id), f"identity[{n}]: bad id {i.id!r}")
|
|
461
|
+
w = f"identity {i.id!r}"
|
|
462
|
+
_check_person(i, w, required=True)
|
|
463
|
+
_check_host(i.host, w)
|
|
464
|
+
check(isinstance(i.owners, tuple), f"{w}: owners must be a list")
|
|
465
|
+
for o in i.owners:
|
|
466
|
+
check(isinstance(o, str) and OWNER_RE.match(o), f"{w}: bad owner {o!r}")
|
|
467
|
+
check(isinstance(i.ssh_user, str) and USER_RE.match(i.ssh_user), f"{w}: bad ssh_user")
|
|
468
|
+
check(i.ssh_port is None or (type(i.ssh_port) is int and 0 < i.ssh_port < 65536),
|
|
469
|
+
f"{w}: bad ssh_port")
|
|
470
|
+
_check_path(i.ssh_key, f"{w}.ssh_key")
|
|
471
|
+
_check_path(i.file, f"{w}.file")
|
|
472
|
+
_check_bool(i.rewrite, f"{w}.rewrite")
|
|
473
|
+
_check_bool(i.rewrite_https, f"{w}.rewrite_https")
|
|
474
|
+
_check_bool(i.sign, f"{w}.sign", optional=True)
|
|
475
|
+
check(isinstance(i.git, dict), f"{w}.git: must be a table")
|
|
476
|
+
check(not signs(i, d) or i.ssh_key or d.ssh_key, f"{w}: signing needs ssh_key here or in [default]")
|
|
477
|
+
if i.ssh_alias is not None:
|
|
478
|
+
check(isinstance(i.ssh_alias, str) and HOST_RE.match(i.ssh_alias) and i.ssh_alias != i.host,
|
|
479
|
+
f"{w}: bad ssh_alias")
|
|
480
|
+
check(i.ssh_key, f"{w}: ssh_alias needs ssh_key")
|
|
481
|
+
_check_extras(i, w)
|
|
482
|
+
_check_dir(i.policy, w, "policy")
|
|
483
|
+
ids.append(replace(i, file=i.file or f"~/.gitconfig-{i.id}"))
|
|
484
|
+
|
|
485
|
+
check(not _dupes(i.id for i in ids), f"duplicate ids: {_dupes(i.id for i in ids)}")
|
|
486
|
+
check(not _dupes(i.file for i in ids), f"duplicate files: {_dupes(i.file for i in ids)}")
|
|
487
|
+
ssh_names = list(d.ssh_hosts) + [i.ssh_alias for i in ids if i.ssh_alias]
|
|
488
|
+
check(not _dupes(ssh_names), f"duplicate ssh Host names: {_dupes(ssh_names)}")
|
|
489
|
+
|
|
490
|
+
return Config(
|
|
491
|
+
default=d, identities=tuple(ids),
|
|
492
|
+
scan=_scan(data.get("scan", {})),
|
|
493
|
+
guard=_guard(data.get("guard", {}), home),
|
|
494
|
+
clone=_clone(data.get("clone", {})),
|
|
495
|
+
forge=_forge(data.get("forge", {})),
|
|
496
|
+
agents=_agents(data.get("agents", {})),
|
|
497
|
+
policy=_policy(data.get("policy", {})),
|
|
498
|
+
path=path, how=how,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def load(path: Path) -> "tuple[Default, list[Identity]]":
|
|
503
|
+
"""The pre-0.2 shape, kept for existing callers: (Default, list of identities)."""
|
|
504
|
+
c = load_all(path)
|
|
505
|
+
return c.default, list(c.identities)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def load_scan(path: Path) -> Scan:
|
|
509
|
+
"""Only [scan], for scan (02) and lost (03), which must work on a config `init` just wrote with
|
|
510
|
+
placeholders: every other table is ignored, so placeholders or errors elsewhere never matter.
|
|
511
|
+
|
|
512
|
+
It raises ConfigError (it does not return Scan() itself, unlike the first draft of spec 01 §3.3)
|
|
513
|
+
when the file is missing, unreadable, not valid TOML, or its [scan] table is bad. The caller
|
|
514
|
+
decides what that means: scan/lost catch it, print `⚠ <path>: <error>; scanning ~` once and use
|
|
515
|
+
Scan(); a caller that needs the configured roots can report the error instead."""
|
|
516
|
+
data = _read(path)
|
|
517
|
+
return _scan(data.get("scan", {}))
|