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/setup.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Guided, offline configuration: save the TOML, never apply Git or SSH changes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
from .config import ConfigError, check
|
|
16
|
+
|
|
17
|
+
Snapshot = Optional[Tuple[bytes, os.stat_result]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(sub, common, confirm) -> None:
|
|
21
|
+
from .args import capability
|
|
22
|
+
p = sub.add_parser("setup", parents=[common], help="configure identities with a guided terminal wizard")
|
|
23
|
+
p.add_argument("--write", action="store_true", help="allow saving after interactive review (default: preview only)")
|
|
24
|
+
p.set_defaults(func=cmd_setup)
|
|
25
|
+
capability("setup")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _snapshot(path: Path) -> Snapshot:
|
|
29
|
+
try:
|
|
30
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
return None
|
|
33
|
+
except OSError as e:
|
|
34
|
+
raise ConfigError(f"cannot open {path}: {e}; setup requires a regular file, not a symlink") from None
|
|
35
|
+
with os.fdopen(fd, "rb") as f:
|
|
36
|
+
info = os.fstat(f.fileno())
|
|
37
|
+
check(stat.S_ISREG(info.st_mode), f"{path}: setup requires a regular file")
|
|
38
|
+
check(info.st_uid == os.getuid() and not info.st_mode & 0o022,
|
|
39
|
+
f"{path}: must be owned by you and not group/world-writable")
|
|
40
|
+
return f.read(), info
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _same(before: Snapshot, after: Snapshot) -> bool:
|
|
44
|
+
if before is None or after is None:
|
|
45
|
+
return before is after
|
|
46
|
+
a, b = before[1], after[1]
|
|
47
|
+
return before[0] == after[0] and (a.st_dev, a.st_ino, a.st_mode, a.st_mtime_ns) == (
|
|
48
|
+
b.st_dev, b.st_ino, b.st_mode, b.st_mtime_ns)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _save(path: Path, text: str, before: Snapshot) -> Optional[Path]:
|
|
52
|
+
from . import files
|
|
53
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
54
|
+
lock = path.with_name(path.name + ".setup.lock")
|
|
55
|
+
try:
|
|
56
|
+
fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
57
|
+
except FileExistsError:
|
|
58
|
+
raise ConfigError(f"{lock}: another setup may be saving; check it before retrying") from None
|
|
59
|
+
os.close(fd)
|
|
60
|
+
try:
|
|
61
|
+
check(_same(before, _snapshot(path)), f"{path}: changed during setup; nothing overwritten, run setup again")
|
|
62
|
+
backup = files._backup(path, before[0]) if before is not None else None
|
|
63
|
+
check(_same(before, _snapshot(path)), f"{path}: changed while backing up; nothing overwritten")
|
|
64
|
+
files.atomic_write(path, text.encode("utf-8"), 0o600, follow_symlinks=False,
|
|
65
|
+
overwrite=before is not None)
|
|
66
|
+
return backup
|
|
67
|
+
finally:
|
|
68
|
+
lock.unlink()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _block(table: str, values: dict) -> str:
|
|
72
|
+
return table + "\n" + "".join(f"{k} = {json.dumps(v, ensure_ascii=False)}\n" for k, v in values.items())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _suggestions(home: Path) -> dict:
|
|
76
|
+
from . import resolve, view
|
|
77
|
+
result = {}
|
|
78
|
+
for field in ("name", "email"):
|
|
79
|
+
r = resolve._git(home, "config", "--global", "--get", "user." + field)
|
|
80
|
+
if r.returncode == 0:
|
|
81
|
+
value = r.stdout.strip()
|
|
82
|
+
if value and not config.CONTROL_RE.search(value):
|
|
83
|
+
result[field] = value
|
|
84
|
+
elif r.returncode != 1:
|
|
85
|
+
view.warn(f"could not suggest {field} from Git config: {r.stderr.strip()}")
|
|
86
|
+
repo = resolve.is_repo(Path.cwd())
|
|
87
|
+
if repo:
|
|
88
|
+
remotes = resolve.remotes(repo)
|
|
89
|
+
url = remotes.get("origin") or next(iter(remotes.values()), "")
|
|
90
|
+
host, owner, _ = resolve.parse_url(url)
|
|
91
|
+
if host and config.HOST_RE.fullmatch(host):
|
|
92
|
+
result["host"] = host
|
|
93
|
+
if owner and config.OWNER_RE.fullmatch(owner):
|
|
94
|
+
result["owners"] = owner
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate(text: str, path: Path, home: Path) -> config.Config:
|
|
99
|
+
from . import plan
|
|
100
|
+
cfg = config.loads(text, path, "--config")
|
|
101
|
+
env = plan.Env(home)
|
|
102
|
+
targets, _ = plan.build(cfg, env)
|
|
103
|
+
reserved = [t.path for t in targets] + plan.guard_paths(env) + [home / plan.ALLOWED_SIGNERS]
|
|
104
|
+
source = path.resolve()
|
|
105
|
+
for output in reserved:
|
|
106
|
+
target = output.resolve()
|
|
107
|
+
check(source != target and source not in target.parents and target not in source.parents,
|
|
108
|
+
f"config {path} conflicts with managed output {output}; choose another config path")
|
|
109
|
+
return cfg
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_setup(args) -> int:
|
|
113
|
+
from rich.prompt import Confirm, Prompt
|
|
114
|
+
from rich.syntax import Syntax
|
|
115
|
+
from rich.text import Text
|
|
116
|
+
from . import resolve, view
|
|
117
|
+
|
|
118
|
+
check(sys.stdin.isatty() and sys.stdout.isatty(),
|
|
119
|
+
"setup needs an interactive terminal; run gitpro setup there, or use gitpro init to create an example")
|
|
120
|
+
resolve.require_git()
|
|
121
|
+
home = args.home.expanduser().resolve()
|
|
122
|
+
path = Path(args.config or os.environ.get(config.ENV_VAR) or home / config.USER_CONFIG).expanduser().absolute()
|
|
123
|
+
before = _snapshot(path)
|
|
124
|
+
original = before[0].decode("utf-8") if before else ""
|
|
125
|
+
template = resources.files("gitpro").joinpath("example.toml").read_text(encoding="utf-8")
|
|
126
|
+
from_example = bool(original) and config._load_toml(original, path) == config._load_toml(template)
|
|
127
|
+
cfg = config.loads(original, path) if original and not from_example else None
|
|
128
|
+
_validate(original if cfg is not None else "[default]\n", path, home)
|
|
129
|
+
text = original if cfg is not None else (
|
|
130
|
+
"# Created with gitpro setup.\n"
|
|
131
|
+
"# Reference: https://github.com/Yantrakar/gitpro/blob/main/docs/guide.md\n\n")
|
|
132
|
+
out = view.console()
|
|
133
|
+
out.print(Text("gitpro setup", style="bold"))
|
|
134
|
+
out.print("Configure once, then let Git pick the identity for each remote.")
|
|
135
|
+
out.print(Text(f"Config: {path}"))
|
|
136
|
+
out.print("Offline. No keys are created, no accounts are contacted, and no Git/SSH settings are applied.")
|
|
137
|
+
out.print("Ctrl-C cancels the wizard before you confirm Save.\n")
|
|
138
|
+
if cfg is not None:
|
|
139
|
+
out.print("Existing settings and comments will be kept. This wizard adds identities; it does not edit them.")
|
|
140
|
+
for identity in cfg.identities:
|
|
141
|
+
scope = ", ".join(identity.owners) or "all owners"
|
|
142
|
+
out.print(Text(f" {identity.id}: {identity.name} <{identity.email}> on {identity.host} ({scope})"))
|
|
143
|
+
elif from_example:
|
|
144
|
+
out.print("This is the unchanged example template. Saving will replace its placeholders and keep a backup.")
|
|
145
|
+
suggested = _suggestions(home)
|
|
146
|
+
if cfg:
|
|
147
|
+
person_default = cfg.default if cfg.default.email else next(iter(cfg.identities), None)
|
|
148
|
+
if person_default:
|
|
149
|
+
suggested.setdefault("name", person_default.name)
|
|
150
|
+
suggested.setdefault("email", person_default.email)
|
|
151
|
+
|
|
152
|
+
def ask(label: str, default: str = "", validate: Optional[Callable[[str], None]] = None) -> str:
|
|
153
|
+
while True:
|
|
154
|
+
value = Prompt.ask(Text(label), default=default, show_default=bool(default), console=out).strip()
|
|
155
|
+
try:
|
|
156
|
+
config._no_control_chars(value, label)
|
|
157
|
+
if validate:
|
|
158
|
+
validate(value)
|
|
159
|
+
return value
|
|
160
|
+
except ConfigError as e:
|
|
161
|
+
out.print(Text(str(e), style="red"))
|
|
162
|
+
|
|
163
|
+
def person() -> dict:
|
|
164
|
+
name = ask("Commit name", suggested.get("name", ""),
|
|
165
|
+
lambda v: check(v and "<" not in v and ">" not in v, "Enter a name without < or >"))
|
|
166
|
+
email = ask("Commit email", suggested.get("email", ""),
|
|
167
|
+
lambda v: config._check_person(config.Default(name=name, email=v), "identity", True))
|
|
168
|
+
suggested.update(name=name, email=email)
|
|
169
|
+
return {"name": name, "email": email}
|
|
170
|
+
|
|
171
|
+
if cfg is None:
|
|
172
|
+
out.print("A fallback identity applies to repositories that do not match a host/owner rule.")
|
|
173
|
+
fallback = person() if Confirm.ask("Set a fallback identity?", default=False, console=out) else {}
|
|
174
|
+
text += _block("[default]", fallback)
|
|
175
|
+
cfg = config.loads(text, path)
|
|
176
|
+
|
|
177
|
+
ids = {i.id for i in cfg.identities}
|
|
178
|
+
added = 0
|
|
179
|
+
while Confirm.ask("Add an identity for a Git host?", default=not added, console=out):
|
|
180
|
+
while True:
|
|
181
|
+
proposed_id = "work"
|
|
182
|
+
n = 2
|
|
183
|
+
while proposed_id in ids:
|
|
184
|
+
proposed_id, n = f"work-{n}", n + 1
|
|
185
|
+
ident = ask("Identity label (for example work or personal)", proposed_id,
|
|
186
|
+
lambda v: check(config.ID_RE.fullmatch(v) and v not in ids, "Use a unique identity label"))
|
|
187
|
+
values = {"id": ident, **person()}
|
|
188
|
+
host = ask("Git host", suggested.get("host", "github.com"),
|
|
189
|
+
lambda v: config._check_host(v, "Git host"))
|
|
190
|
+
while True:
|
|
191
|
+
owners = ask("Owners or groups, comma-separated (blank means the entire host)",
|
|
192
|
+
suggested.get("owners", "") if host == suggested.get("host") else "",
|
|
193
|
+
lambda v: check(all(config.OWNER_RE.fullmatch(s.strip()) for s in v.split(","))
|
|
194
|
+
if v else True, "Enter valid, comma-separated owners or groups"))
|
|
195
|
+
if owners or Confirm.ask(f"Match EVERY repository on {host}?", default=False, console=out):
|
|
196
|
+
break
|
|
197
|
+
values.update(host=host, owners=list(dict.fromkeys(s.strip() for s in owners.split(",") if s.strip())))
|
|
198
|
+
public_keys = sorted(p for p in (home / ".ssh").glob("*.pub") if p.is_file())
|
|
199
|
+
if public_keys:
|
|
200
|
+
out.print("Existing SSH keys (only public-key filenames are inspected):")
|
|
201
|
+
for pub in public_keys:
|
|
202
|
+
out.print(Text(f" {pub.with_suffix('')}"))
|
|
203
|
+
|
|
204
|
+
def key_path(v: str) -> None:
|
|
205
|
+
if v:
|
|
206
|
+
config._check_path(v, "SSH key")
|
|
207
|
+
key = home / v[2:] if v.startswith("~/") else Path(v)
|
|
208
|
+
check(not v.endswith(".pub") and key.is_file(), "Choose an existing private-key path, not .pub")
|
|
209
|
+
|
|
210
|
+
key = ask("SSH key path (blank keeps your existing authentication)", validate=key_path)
|
|
211
|
+
if key:
|
|
212
|
+
values["ssh_key"] = key
|
|
213
|
+
alias = "gitpro-" + (re.sub(r"[^a-z0-9-]", "-", ident.lower()).strip("-") or "identity")
|
|
214
|
+
values["ssh_alias"] = ask("SSH alias for this identity", alias,
|
|
215
|
+
lambda v: check(config.HOST_RE.fullmatch(v) and v != host,
|
|
216
|
+
"Choose an SSH alias different from the Git host"))
|
|
217
|
+
values["sign"] = Confirm.ask("Sign commits with this SSH key?", default=False, console=out)
|
|
218
|
+
if values["sign"]:
|
|
219
|
+
private = home / key[2:] if key.startswith("~/") else Path(key)
|
|
220
|
+
if not Path(str(private) + ".pub").is_file():
|
|
221
|
+
out.print("The matching .pub file is missing. Choose a key with a public key to enable signing.")
|
|
222
|
+
continue
|
|
223
|
+
else:
|
|
224
|
+
values["sign"] = False
|
|
225
|
+
block = "\n\n" + _block("[[identity]]", values)
|
|
226
|
+
try:
|
|
227
|
+
cfg = _validate(text + block, path, home)
|
|
228
|
+
except ConfigError as e:
|
|
229
|
+
out.print(Text(f"{e}\nPlease enter this identity again; nothing has been saved.", style="red"))
|
|
230
|
+
continue
|
|
231
|
+
text += block
|
|
232
|
+
ids.add(ident)
|
|
233
|
+
added += 1
|
|
234
|
+
break
|
|
235
|
+
if text == original:
|
|
236
|
+
view.ok("configuration left unchanged")
|
|
237
|
+
return 0
|
|
238
|
+
if cfg.default.email is None and not cfg.identities:
|
|
239
|
+
view.aborted()
|
|
240
|
+
return 1
|
|
241
|
+
_validate(text, path, home)
|
|
242
|
+
out.print(Text("\nReview the configuration additions" if original and not from_example else
|
|
243
|
+
"\nReview your configuration", style="bold"))
|
|
244
|
+
preview = text[len(original):] if original and not from_example else text
|
|
245
|
+
out.print(Syntax(preview, "toml", word_wrap=True))
|
|
246
|
+
if not args.write:
|
|
247
|
+
out.print("Dry run: nothing saved. Run gitpro setup --write to review and save.")
|
|
248
|
+
return 0
|
|
249
|
+
out.print("Only the config file will be saved. Existing config files are backed up first.")
|
|
250
|
+
if not Confirm.ask("Save this configuration?", default=False, console=out):
|
|
251
|
+
view.aborted()
|
|
252
|
+
return 1
|
|
253
|
+
backup = _save(path, text, before)
|
|
254
|
+
view.ok(f"saved {path}")
|
|
255
|
+
if backup:
|
|
256
|
+
view.step(f"backup: {backup}")
|
|
257
|
+
extra = " -c " + shlex.quote(str(path))
|
|
258
|
+
view.step(f"review Git/SSH changes: gitpro plan{extra}")
|
|
259
|
+
view.step(f"apply them when ready: gitpro apply{extra}")
|
|
260
|
+
return 0
|
gitpro/trailers.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Trailer policy per identity (`co_authored_by = allow | forbid | require`, `forbid_emails`) and the
|
|
2
|
+
guard's commit-msg entry `hook_message(ctx)`.
|
|
3
|
+
|
|
4
|
+
Hot path: the commit-msg hook imports this module, so it imports only the standard library and
|
|
5
|
+
gitpro.config at module level (resolve is imported inside hook_message, where guard has loaded it already).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from .config import ConfigError
|
|
17
|
+
|
|
18
|
+
BLOCK = 3 # guard.BLOCK; not imported, guard is not needed to judge a message
|
|
19
|
+
GIT_TIMEOUT = 5 # guard.GIT_TIMEOUT: every git call inside a hook
|
|
20
|
+
CO_AUTHOR = "co-authored-by"
|
|
21
|
+
VALUES = ("allow", "forbid", "require")
|
|
22
|
+
|
|
23
|
+
Trailer = Tuple[str, str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Policy:
|
|
28
|
+
"""The trailer policy that applies to one identity, and where each part came from (for messages)."""
|
|
29
|
+
co_authored_by: str = "allow" # "allow" | "forbid" | "require"
|
|
30
|
+
forbid_emails: Tuple[str, ...] = ()
|
|
31
|
+
who: str = "the [default] identity" # "identity work-github" | "the [default] identity"
|
|
32
|
+
table: str = "" # "[identity.trailers]" | "[default.trailers]" | "" (built-in allow)
|
|
33
|
+
emails_table: str = "" # the same, for forbid_emails
|
|
34
|
+
|
|
35
|
+
def active(self) -> bool:
|
|
36
|
+
"""False when nothing can be a violation (allow and no forbid_emails): no need to read trailers."""
|
|
37
|
+
return self.co_authored_by != "allow" or bool(self.forbid_emails)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _trailers_of(obj: object) -> object:
|
|
41
|
+
"""An Identity / Default's `trailers`, or obj itself when it already is a Trailers (or None)."""
|
|
42
|
+
return getattr(obj, "trailers", obj)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def policy_for(identity: object, default: object) -> Policy:
|
|
46
|
+
"""identity's [identity.trailers] value, else [default.trailers], else allow / no forbidden emails.
|
|
47
|
+
identity: config.Identity or None (no identity matched); default: config.Default (or its Trailers)."""
|
|
48
|
+
it, dt = _trailers_of(identity), _trailers_of(default)
|
|
49
|
+
who = f"identity {identity.id}" if getattr(identity, "id", None) else "the [default] identity"
|
|
50
|
+
co, table = "allow", ""
|
|
51
|
+
for t, name in ((it, "[identity.trailers]"), (dt, "[default.trailers]")):
|
|
52
|
+
if t is not None and getattr(t, "co_authored_by", None):
|
|
53
|
+
co, table = t.co_authored_by, name
|
|
54
|
+
break
|
|
55
|
+
emails, etable = (), ""
|
|
56
|
+
for t, name in ((it, "[identity.trailers]"), (dt, "[default.trailers]")):
|
|
57
|
+
if t is not None and getattr(t, "forbid_emails", None) is not None:
|
|
58
|
+
emails, etable = tuple(t.forbid_emails), name
|
|
59
|
+
break
|
|
60
|
+
return Policy(co, emails, who, table, etable)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------- matching
|
|
64
|
+
|
|
65
|
+
def glob_match(pattern: str, text: str) -> bool:
|
|
66
|
+
"""Case-insensitive glob with `*` and `?`; `[` is always a literal character, never a class:
|
|
67
|
+
"*[bot]@users.noreply.github.com" matches "dependabot[bot]@users.noreply.github.com" but not
|
|
68
|
+
"123+scott@users.noreply.github.com"."""
|
|
69
|
+
return fnmatch.fnmatchcase(text.casefold(), pattern.casefold().replace("[", "[[]"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def email_of(value: str) -> str:
|
|
73
|
+
"""The address in a trailer value "Name <email>", else the value itself."""
|
|
74
|
+
v = value.strip()
|
|
75
|
+
lt, gt = v.rfind("<"), v.rfind(">")
|
|
76
|
+
return v[lt + 1:gt].strip() if 0 <= lt < gt else v
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_co_author(key: str) -> bool:
|
|
80
|
+
return key.strip().casefold() == CO_AUTHOR
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def split_lines(text: str) -> List[Trailer]:
|
|
84
|
+
"""(key, value) per "Key: value" line, as `git interpret-trailers --parse` and %(trailers:only,unfold)
|
|
85
|
+
print them."""
|
|
86
|
+
out: List[Trailer] = []
|
|
87
|
+
for line in text.splitlines():
|
|
88
|
+
key, sep, value = line.partition(":")
|
|
89
|
+
if sep and key.strip():
|
|
90
|
+
out.append((key.strip(), value.strip()))
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- checking
|
|
95
|
+
|
|
96
|
+
def violations(trailers: Sequence[Trailer], policy: Policy) -> List[Tuple[str, str]]:
|
|
97
|
+
"""(message, fix) per violation of policy by a commit message carrying `trailers`."""
|
|
98
|
+
out: List[Tuple[str, str]] = []
|
|
99
|
+
co = [(k, v) for k, v in trailers if is_co_author(k)]
|
|
100
|
+
for k, v in co:
|
|
101
|
+
shown = f"{k}: {v}"
|
|
102
|
+
if policy.co_authored_by == "forbid":
|
|
103
|
+
out.append((f"{shown} is not allowed for {policy.who} "
|
|
104
|
+
f"({policy.table} co_authored_by = \"forbid\")", "remove the trailer from the message"))
|
|
105
|
+
continue
|
|
106
|
+
hit = next((g for g in policy.forbid_emails if glob_match(g, email_of(v))), None)
|
|
107
|
+
if hit is not None:
|
|
108
|
+
out.append((f"{shown} is not allowed for {policy.who} ({policy.emails_table} forbid_emails has "
|
|
109
|
+
f"\"{hit}\")", "remove the trailer from the message"))
|
|
110
|
+
if policy.co_authored_by == "require" and not co:
|
|
111
|
+
out.append((f"{policy.who} requires a Co-Authored-By trailer on every commit",
|
|
112
|
+
"end the message with a trailer line: Co-Authored-By: Name <email>"))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check(trailers: Sequence[Trailer], policy: Policy) -> List[str]:
|
|
117
|
+
"""The violation messages (empty when the trailers satisfy the policy)."""
|
|
118
|
+
return [m for m, _ in violations(trailers, policy)]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------- reading trailers
|
|
122
|
+
|
|
123
|
+
def _interpret(argv: List[str], stdin: Optional[str] = None) -> List[Trailer]:
|
|
124
|
+
try:
|
|
125
|
+
r = subprocess.run(["git", "interpret-trailers", "--parse", "--no-divider", *argv], input=stdin,
|
|
126
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
127
|
+
timeout=GIT_TIMEOUT, **({} if stdin is not None else {"stdin": subprocess.DEVNULL}))
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
raise ConfigError("git not found") from None
|
|
130
|
+
except subprocess.TimeoutExpired:
|
|
131
|
+
raise ConfigError(f"git interpret-trailers timed out after {GIT_TIMEOUT}s") from None
|
|
132
|
+
if r.returncode:
|
|
133
|
+
raise ConfigError(f"git interpret-trailers failed: {r.stderr.strip() or r.returncode}")
|
|
134
|
+
return split_lines(r.stdout)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse(file: object) -> List[Trailer]:
|
|
138
|
+
"""The trailers of the commit message in `file`, as git sees them (`git interpret-trailers --parse`:
|
|
139
|
+
comment lines and a scissors line are ignored, folded values unfolded)."""
|
|
140
|
+
return _interpret([str(file)])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_text(message: str) -> List[Trailer]:
|
|
144
|
+
"""parse() for a message held in a string."""
|
|
145
|
+
return _interpret([], message)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------- commit-msg entry (01 §5.6)
|
|
149
|
+
|
|
150
|
+
def _quiet_already(ctx) -> bool:
|
|
151
|
+
"""guard's warn-once rule: pre-commit of this git command already printed a fail-open warning."""
|
|
152
|
+
f, pid = os.environ.get("GITPRO_WARNED_FILE"), os.environ.get("GITPRO_GIT_PID")
|
|
153
|
+
if not (f and pid and ctx.hook == "commit-msg"):
|
|
154
|
+
return False
|
|
155
|
+
try:
|
|
156
|
+
return Path(f).read_text(encoding="utf-8", errors="replace").strip() == pid
|
|
157
|
+
except OSError:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _in_merge() -> bool:
|
|
162
|
+
"""A merge commit is being made (git merge runs commit-msg too); merges need no Co-Authored-By."""
|
|
163
|
+
try:
|
|
164
|
+
r = subprocess.run(["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"], capture_output=True, text=True,
|
|
165
|
+
stdin=subprocess.DEVNULL, timeout=GIT_TIMEOUT)
|
|
166
|
+
except (OSError, subprocess.SubprocessError):
|
|
167
|
+
return False
|
|
168
|
+
return r.returncode == 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _policy(ctx) -> Policy:
|
|
172
|
+
report = getattr(ctx, "report", None)
|
|
173
|
+
if report is not None:
|
|
174
|
+
exp = report.expected
|
|
175
|
+
else:
|
|
176
|
+
from . import resolve
|
|
177
|
+
exp = resolve.expected(Path(ctx.repo), ctx.cfg)
|
|
178
|
+
return policy_for(exp.match.identity, ctx.cfg.default)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _hook_message(ctx) -> int:
|
|
182
|
+
if not ctx.args:
|
|
183
|
+
raise ConfigError("commit-msg called without a message file")
|
|
184
|
+
policy = _policy(ctx)
|
|
185
|
+
if not policy.active():
|
|
186
|
+
return 0
|
|
187
|
+
if policy.co_authored_by == "require" and _in_merge():
|
|
188
|
+
policy = replace(policy, co_authored_by="allow")
|
|
189
|
+
if not policy.active():
|
|
190
|
+
return 0
|
|
191
|
+
found = violations(parse(ctx.args[0]), policy)
|
|
192
|
+
if not found:
|
|
193
|
+
return 0
|
|
194
|
+
warn = getattr(getattr(ctx.cfg, "guard", None), "on_mismatch", "block") != "block"
|
|
195
|
+
for msg, _ in found:
|
|
196
|
+
ctx.say(("⚠" if warn else "✗") + f" gitpro: {msg}")
|
|
197
|
+
for fix in dict.fromkeys(f for _, f in found):
|
|
198
|
+
ctx.say(f" fix: {fix}")
|
|
199
|
+
if warn:
|
|
200
|
+
ctx.say(' (not blocking: [guard] on_mismatch = "warn")')
|
|
201
|
+
return BLOCK
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def hook_message(ctx) -> int:
|
|
205
|
+
"""guard.HOOKS["commit-msg"] entry: BLOCK (3) when the message's trailers break the trailer policy of
|
|
206
|
+
the repo's identity, else 0. Never raises: any failure is one ⚠ line and 0 (fail open)."""
|
|
207
|
+
try:
|
|
208
|
+
return _hook_message(ctx)
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
raise
|
|
211
|
+
except BaseException as e: # noqa: BLE001 -- a broken gitpro must never block a commit
|
|
212
|
+
msg = str(e) if isinstance(e, ConfigError) else (f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
|
|
213
|
+
try:
|
|
214
|
+
if not _quiet_already(ctx):
|
|
215
|
+
ctx.say(f"⚠ gitpro trailers: {msg}; not blocking")
|
|
216
|
+
except Exception: # noqa: BLE001
|
|
217
|
+
pass
|
|
218
|
+
return 0
|
gitpro/tui.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Interactive scan --fix (optional: pip install 'gitpro[tui]'): tick the repos to fix, preview, confirm."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable, List, Optional
|
|
5
|
+
|
|
6
|
+
from rich.syntax import Syntax
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, DataTable, Footer, Header, Label, Static
|
|
13
|
+
|
|
14
|
+
from .view import plural, who
|
|
15
|
+
|
|
16
|
+
ON, OFF = "☑", "☐"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Ask(ModalScreen[bool]):
|
|
20
|
+
BINDINGS = [Binding("y", "answer(True)", "Remove"), Binding("n,escape", "answer(False)", "Cancel")]
|
|
21
|
+
|
|
22
|
+
def __init__(self, question: str) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.question = question
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
with Vertical(classes="dialog"):
|
|
28
|
+
yield Label(self.question)
|
|
29
|
+
with Horizontal(classes="buttons"):
|
|
30
|
+
yield Button("Remove", variant="error", id="yes")
|
|
31
|
+
yield Button("Cancel", id="no")
|
|
32
|
+
|
|
33
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
34
|
+
self.dismiss(event.button.id == "yes")
|
|
35
|
+
|
|
36
|
+
def action_answer(self, yes: bool) -> None:
|
|
37
|
+
self.dismiss(yes)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Suggestion(ModalScreen[None]):
|
|
41
|
+
BINDINGS = [Binding("c", "copy", "Copy"), Binding("escape,q,s", "app.pop_screen", "Close")]
|
|
42
|
+
|
|
43
|
+
def __init__(self, toml: str) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.toml = toml
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
with Vertical(classes="dialog"):
|
|
49
|
+
yield Static("To keep this identity, paste the block into your config and run 'gitpro apply'. "
|
|
50
|
+
"The repos it matches then keep it after the fix.\n")
|
|
51
|
+
yield Static(Syntax(self.toml, "toml", theme="ansi_dark", background_color="default"))
|
|
52
|
+
yield Footer()
|
|
53
|
+
|
|
54
|
+
def action_copy(self) -> None:
|
|
55
|
+
self.app.copy_to_clipboard(self.toml)
|
|
56
|
+
self.notify("copied")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ScanApp(App[Optional[List[str]]]): # not list[str] | None: evaluated at runtime on 3.9
|
|
60
|
+
TITLE = "gitpro scan"
|
|
61
|
+
CSS = """
|
|
62
|
+
#target { padding: 0 1; color: $text-muted; }
|
|
63
|
+
DataTable { height: 1fr; }
|
|
64
|
+
#detail { height: auto; max-height: 12; border-top: solid $primary; padding: 0 1; }
|
|
65
|
+
Ask, Suggestion { align: center middle; }
|
|
66
|
+
.dialog Static { width: 100%; }
|
|
67
|
+
.dialog { width: 90%; max-width: 100; height: auto; border: thick $primary; background: $surface; padding: 1 2; }
|
|
68
|
+
.buttons { height: auto; margin-top: 1; }
|
|
69
|
+
.buttons Button { margin-right: 2; }
|
|
70
|
+
"""
|
|
71
|
+
BINDINGS = [
|
|
72
|
+
Binding("space", "toggle", "Tick"),
|
|
73
|
+
Binding("a", "toggle_all", "All"),
|
|
74
|
+
Binding("s", "suggest", "Keep identity"),
|
|
75
|
+
Binding("enter", "fix", "Fix ticked", priority=True),
|
|
76
|
+
Binding("q,escape", "quit", "Quit"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def __init__(self, rep: dict, show: Callable[[str], str]) -> None:
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.rep, self.show = rep, show
|
|
82
|
+
fixable = [r for r in rep["overrides"] if r["fixable"]]
|
|
83
|
+
# Same order as the plain output: by identity after the fix, then by the identity now, unchanged last.
|
|
84
|
+
self.rows = sorted(fixable, key=lambda r: (who(r["after"]), who(r["now"]) == who(r["after"]),
|
|
85
|
+
who(r["now"]), r["short"]))
|
|
86
|
+
self.ticked = {r["path"] for r in self.rows}
|
|
87
|
+
|
|
88
|
+
def compose(self) -> ComposeResult:
|
|
89
|
+
yield Header()
|
|
90
|
+
yield Static(id="target")
|
|
91
|
+
yield DataTable(cursor_type="row", zebra_stripes=True)
|
|
92
|
+
yield Static(id="detail")
|
|
93
|
+
yield Footer()
|
|
94
|
+
|
|
95
|
+
def on_mount(self) -> None:
|
|
96
|
+
table = self.query_one(DataTable)
|
|
97
|
+
table.add_column(" ", key="tick")
|
|
98
|
+
targets = {who(r["after"]) for r in self.rows}
|
|
99
|
+
one = len(targets) == 1
|
|
100
|
+
table.add_columns("repo", "now (yellow: changes)" if one else "now", *([] if one else ["after the fix"]))
|
|
101
|
+
target = self.query_one("#target", Static)
|
|
102
|
+
if one:
|
|
103
|
+
target.update(Text("After the fix they all use ") + Text(targets.pop(), style="bold green"))
|
|
104
|
+
else:
|
|
105
|
+
target.display = False
|
|
106
|
+
for r in self.rows:
|
|
107
|
+
now, after = who(r["now"]), who(r["after"])
|
|
108
|
+
cells = [Text(now, style="yellow" if now != after else "dim")]
|
|
109
|
+
if not one:
|
|
110
|
+
cells.append(Text("unchanged", style="dim") if now == after else Text(after, style="green"))
|
|
111
|
+
table.add_row(ON, r["short"], *cells, key=r["path"])
|
|
112
|
+
self.sub_title = self.counts()
|
|
113
|
+
table.focus()
|
|
114
|
+
|
|
115
|
+
def counts(self) -> str:
|
|
116
|
+
n = sum(len(r["fixable"]) for r in self.rows if r["path"] in self.ticked)
|
|
117
|
+
return f"{plural(len(self.ticked), 'repo')} ticked, {plural(n, 'setting')} to remove"
|
|
118
|
+
|
|
119
|
+
def current(self) -> dict | None:
|
|
120
|
+
table = self.query_one(DataTable)
|
|
121
|
+
return self.rows[table.cursor_row] if self.rows else None
|
|
122
|
+
|
|
123
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
124
|
+
r = self.current()
|
|
125
|
+
if not r:
|
|
126
|
+
return
|
|
127
|
+
text = Text(self.show(r["path"]), style="bold")
|
|
128
|
+
text.append(f" {r['owner']}\n", style="dim")
|
|
129
|
+
for s in r["settings"]:
|
|
130
|
+
text.append(f" {s['key']:<11} {s['value']}")
|
|
131
|
+
text.append(" → " + s["without"] if s["without"] else " (kept: nothing else sets it)",
|
|
132
|
+
style="green" if s["without"] and s["without"] != s["value"] else "dim")
|
|
133
|
+
text.append("\n")
|
|
134
|
+
if self.suggestion(r):
|
|
135
|
+
text.append("s: keep this identity with a gitpro identity instead", style="dim")
|
|
136
|
+
self.query_one("#detail", Static).update(text)
|
|
137
|
+
|
|
138
|
+
def suggestion(self, r: dict) -> dict | None:
|
|
139
|
+
return next((s for s in self.rep["suggestions"] if s["email"] == r["now"]["email"]
|
|
140
|
+
and r["owner"] in {f"{s['host']}/{o}" for o in s["owners"]}), None)
|
|
141
|
+
|
|
142
|
+
def action_toggle(self) -> None:
|
|
143
|
+
r = self.current()
|
|
144
|
+
if r:
|
|
145
|
+
self.ticked ^= {r["path"]}
|
|
146
|
+
self.query_one(DataTable).update_cell(r["path"], "tick", ON if r["path"] in self.ticked else OFF)
|
|
147
|
+
self.sub_title = self.counts()
|
|
148
|
+
|
|
149
|
+
def action_toggle_all(self) -> None:
|
|
150
|
+
self.ticked = set() if self.ticked else {r["path"] for r in self.rows}
|
|
151
|
+
table = self.query_one(DataTable)
|
|
152
|
+
for r in self.rows:
|
|
153
|
+
table.update_cell(r["path"], "tick", ON if r["path"] in self.ticked else OFF)
|
|
154
|
+
self.sub_title = self.counts()
|
|
155
|
+
|
|
156
|
+
def action_suggest(self) -> None:
|
|
157
|
+
r = self.current()
|
|
158
|
+
s = r and self.suggestion(r)
|
|
159
|
+
if s:
|
|
160
|
+
self.push_screen(Suggestion(s["toml"]))
|
|
161
|
+
else:
|
|
162
|
+
self.notify("no identity can keep this one: it has no remote gitpro can match", severity="warning")
|
|
163
|
+
|
|
164
|
+
def action_fix(self) -> None:
|
|
165
|
+
if not self.ticked:
|
|
166
|
+
self.notify("nothing ticked", severity="warning")
|
|
167
|
+
return
|
|
168
|
+
n = sum(len(r["fixable"]) for r in self.rows if r["path"] in self.ticked)
|
|
169
|
+
self.push_screen(Ask(f"Remove {plural(n, 'setting')} from {plural(len(self.ticked), 'repo')}?"),
|
|
170
|
+
lambda yes: self.exit(sorted(self.ticked)) if yes else None)
|
|
171
|
+
|
|
172
|
+
def action_quit(self) -> None:
|
|
173
|
+
self.exit(None)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def choose(rep: dict, show: Callable[[str], str]) -> list[str] | None:
|
|
177
|
+
"""Paths of the repos to fix, or None if the user quit."""
|
|
178
|
+
return ScanApp(rep, show).run()
|