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/hot.py ADDED
@@ -0,0 +1,38 @@
1
+ """Entry point (`gitpro` script and `python -m gitpro`). Nothing but stdlib at module level.
2
+
3
+ The hot subcommands (git hooks, agent hooks, shell prompt) run on every commit or prompt, so they go
4
+ straight to their own module without importing cli.py, rich or the views. Everything else, and any
5
+ hot subcommand whose package is not built yet, falls through to cli.main.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import os
11
+ import sys
12
+ from typing import List, Optional
13
+
14
+ HOT = { # subcommand -> module with main(argv) -> int (argv includes the subcommand)
15
+ "guard": "gitpro.guard", # 06
16
+ "agent-check": "gitpro.agents", # 10
17
+ "prompt": "gitpro.prompt", # 16
18
+ }
19
+
20
+ CALLER_UMASK: Optional[int] = None # the umask gitpro was started with (set before tightening to 077)
21
+
22
+
23
+ def main(argv: Optional[List[str]] = None) -> int:
24
+ global CALLER_UMASK
25
+ argv = sys.argv[1:] if argv is None else list(argv)
26
+ if argv and argv[0] in HOT and os.name == "posix" and os.geteuid() != 0:
27
+ name = HOT[argv[0]]
28
+ try:
29
+ mod = importlib.import_module(name)
30
+ except ModuleNotFoundError as e:
31
+ if e.name != name: # a broken import inside the module is not hidden
32
+ raise
33
+ mod = None # package not built yet: fall through to cli
34
+ if mod is not None:
35
+ CALLER_UMASK = os.umask(0o077)
36
+ return mod.main(argv)
37
+ from gitpro.cli import main as cli_main
38
+ return cli_main(argv)
gitpro/jsonfile.py ADDED
@@ -0,0 +1,195 @@
1
+ """Read and edit JSON / JSONC settings files with a preview, a backup and an undo script (10, 11, 13)."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import difflib
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Callable, Optional, Tuple, Union
13
+
14
+ from . import files, undo
15
+ from .config import ConfigError
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Result:
20
+ path: Path
21
+ changed: bool
22
+ diff: str
23
+ backup: Optional[Path]
24
+ undo: Optional[Path]
25
+ text: str # what is (or would be) on disk after the edit
26
+
27
+
28
+ def strip_jsonc(text: str) -> Tuple[str, bool]:
29
+ """(text without // and /* */ comments outside strings and without trailing commas, had comments)."""
30
+ out, i, n, comments = [], 0, len(text), False
31
+ while i < n:
32
+ c = text[i]
33
+ if c == '"':
34
+ j = i + 1
35
+ while j < n and text[j] != '"':
36
+ j += 2 if text[j] == "\\" else 1
37
+ out.append(text[i:j + 1])
38
+ i = j + 1
39
+ elif text.startswith("//", i):
40
+ comments = True
41
+ j = text.find("\n", i)
42
+ i = n if j < 0 else j
43
+ elif text.startswith("/*", i):
44
+ comments = True
45
+ j = text.find("*/", i + 2)
46
+ i = n if j < 0 else j + 2
47
+ out.append(" ")
48
+ else:
49
+ out.append(c)
50
+ i += 1
51
+ return _drop_trailing_commas("".join(out)), comments
52
+
53
+
54
+ def _drop_trailing_commas(text: str) -> str:
55
+ out, i, n = [], 0, len(text)
56
+ while i < n:
57
+ c = text[i]
58
+ if c == '"':
59
+ j = i + 1
60
+ while j < n and text[j] != '"':
61
+ j += 2 if text[j] == "\\" else 1
62
+ out.append(text[i:j + 1])
63
+ i = j + 1
64
+ continue
65
+ if c == ",":
66
+ m = re.compile(r"\s*[}\]]").match(text, i + 1)
67
+ if m:
68
+ i += 1
69
+ continue
70
+ out.append(c)
71
+ i += 1
72
+ return "".join(out)
73
+
74
+
75
+ def read(path: Path, *, jsonc: bool = False) -> Tuple[object, str]:
76
+ """(parsed object, original text, line endings kept). Raises ConfigError("<path>: cannot parse (<error>)")."""
77
+ with open(path, encoding="utf-8", newline="") as f:
78
+ text = f.read()
79
+ return _parse(path, text, jsonc)[0], text
80
+
81
+
82
+ def _parse(path: Path, text: str, jsonc: bool) -> Tuple[object, bool]:
83
+ src, comments = strip_jsonc(text) if jsonc else (text, False)
84
+ try:
85
+ return json.loads(src), comments
86
+ except ValueError as e:
87
+ raise ConfigError(f"{path}: cannot parse ({e})") from None
88
+
89
+
90
+ def detect_indent(text: str) -> Union[int, str]:
91
+ """The file's own indent when it is 2 or 4 spaces or a tab, else 2."""
92
+ m = re.search(r"[{\[][ \t]*\r?\n([ \t]+)\S", text)
93
+ if m:
94
+ ws = m.group(1)
95
+ if ws == "\t":
96
+ return "\t"
97
+ if ws in (" ", " "):
98
+ return len(ws)
99
+ return 2
100
+
101
+
102
+ def _mode_for(path: Path, old_mode: Optional[int]) -> int:
103
+ home = Path.home()
104
+ real = Path(os.path.realpath(path))
105
+ for private in (home / ".ssh", home / ".config/gh"):
106
+ if private in real.parents or private in Path(path).parents:
107
+ return 0o600
108
+ return old_mode if old_mode is not None else 0o644
109
+
110
+
111
+ def _backup(path: Path, text: str) -> Path:
112
+ """A 0600 copy in <state>/backup-<ts>/ (never next to the file: it may be in a work tree)."""
113
+ d = undo.state_dir() / f"backup-{undo._stamp()}"
114
+ d.mkdir(mode=0o700, exist_ok=True)
115
+ for n in range(1000):
116
+ bak = d / (path.name + (f".{n}" if n else ""))
117
+ try:
118
+ fd = os.open(bak, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
119
+ except FileExistsError:
120
+ continue
121
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
122
+ f.write(text) # the original bytes: CRLF stays CRLF, so the undo restores the file exactly
123
+ return bak
124
+ raise OSError(f"no free backup name in {d}")
125
+
126
+
127
+ def _current(path: Path) -> Optional[str]:
128
+ """The file's text with its own line endings (newline=""), None when it does not exist."""
129
+ try:
130
+ with open(path, encoding="utf-8", newline="") as f:
131
+ return f.read()
132
+ except FileNotFoundError:
133
+ return None
134
+
135
+
136
+ def _discard(*paths: Optional[Path]) -> None:
137
+ """Remove a backup and undo script whose write failed (and the backup's directory once empty)."""
138
+ for p in paths:
139
+ if p is None:
140
+ continue
141
+ try:
142
+ p.unlink()
143
+ if p.parent.name.startswith("backup-"):
144
+ p.parent.rmdir()
145
+ except OSError:
146
+ pass
147
+
148
+
149
+ def edit_json(path: Path, mutate: Callable[[object], object], *, command: str, jsonc: bool = False,
150
+ create: Optional[object] = None, confirm: Callable[[str], bool], indent: int = 2,
151
+ mkdir: bool = False, snippet: Optional[str] = None) -> Result:
152
+ """Apply mutate to the file's JSON; show the diff to confirm; back up, write an undo script, then
153
+ replace the file atomically. `indent` is used for new files; existing files keep their own indent.
154
+ `snippet` is what the caller would have the user add by hand when a JSONC file has comments."""
155
+ path = Path(path)
156
+ old = _current(path)
157
+ if old is None:
158
+ if create is None:
159
+ raise ConfigError(f"{path}: not found")
160
+ if not path.parent.is_dir() and not mkdir:
161
+ raise ConfigError(f"{path.parent}: no such directory")
162
+ obj, comments, width, old_mode = create, False, indent, None
163
+ else:
164
+ obj, comments = _parse(path, old, jsonc)
165
+ width = detect_indent(old)
166
+ old_mode = path.stat().st_mode & 0o777
167
+ new_obj = mutate(copy.deepcopy(obj))
168
+ text = json.dumps(new_obj, indent=width, ensure_ascii=False) + "\n"
169
+ diff = "".join(difflib.unified_diff((old or "").replace("\r\n", "\n").splitlines(True), text.splitlines(True),
170
+ f"a{path}", f"b{path}"))
171
+ if old is not None and "\r\n" in old:
172
+ text = text.replace("\n", "\r\n") # keep a CRLF file CRLF (json.dumps escapes newlines in strings)
173
+ if old is not None and new_obj == obj:
174
+ return Result(path, False, "", None, None, old)
175
+ if comments:
176
+ raise ConfigError(f"{path} has comments; gitpro cannot edit it without losing them"
177
+ f" — add this by hand: {snippet or diff}")
178
+ if not confirm(diff):
179
+ return Result(path, False, diff, None, None, text)
180
+ if _current(path) != old:
181
+ raise ConfigError(f"{path} changed while gitpro was running; nothing written")
182
+ backup = _backup(path, old) if old is not None else None
183
+ target = shlex.quote(str(path))
184
+ # The backup is 0600; the undo restores the file's own mode, not the backup's.
185
+ lines = [f"cp {shlex.quote(str(backup))} {target}", f"chmod {old_mode:04o} {target}"] if backup \
186
+ else [f"rm -f {target}"]
187
+ script = undo.write(command, lines, what=f"edited {undo.tilde(path)}")
188
+ if old is None and mkdir:
189
+ path.parent.mkdir(parents=True, exist_ok=True)
190
+ try:
191
+ files.atomic_write(path, text, _mode_for(path, old_mode))
192
+ except BaseException:
193
+ _discard(backup, script) # nothing changed: leave no backup or undo script behind
194
+ raise
195
+ return Result(path, True, diff, backup, script, text)
gitpro/jsonout.py ADDED
@@ -0,0 +1,26 @@
1
+ """The --json envelope every command shares: {"ok": ..., "command": ..., ...}."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+
7
+
8
+ def dumps(obj: dict) -> str:
9
+ return json.dumps(obj, indent=2, ensure_ascii=False)
10
+
11
+
12
+ def emit(command: str, obj: dict, *, ok: bool = True) -> None:
13
+ """Print {"ok", "command", **obj} on stdout; an "ok" inside obj wins over the argument."""
14
+ out = {"ok": obj.get("ok", ok), "command": command}
15
+ out.update((k, v) for k, v in obj.items() if k not in ("ok", "command"))
16
+ sys.stdout.write(dumps(out) + "\n")
17
+ sys.stdout.flush()
18
+
19
+
20
+ def error(command: str, message: str) -> None:
21
+ """The error object, on stdout; nothing goes to stderr."""
22
+ emit(command, {"error": message}, ok=False)
23
+
24
+
25
+ def wants_json(args: object) -> bool:
26
+ return bool(getattr(args, "json", False))
gitpro/keys.py ADDED
@@ -0,0 +1,59 @@
1
+ """SSH keys: which key serves which identity, generating missing ones, printing .pub files."""
2
+ from __future__ import annotations
3
+
4
+ import socket
5
+ import subprocess
6
+ from dataclasses import dataclass, field
7
+
8
+ from .config import Default, Identity, signs
9
+ from .render import Expand, pub_path
10
+
11
+
12
+ @dataclass
13
+ class Key:
14
+ path: str
15
+ email: str
16
+ uses: list[str] = field(default_factory=list)
17
+ sign: bool = False
18
+
19
+
20
+ def inventory(d: Default, ids: list[Identity]) -> list[Key]:
21
+ keys: dict[str, Key] = {}
22
+
23
+ def use(path: str, email: str, what: str, sign: bool) -> None:
24
+ k = keys.setdefault(path, Key(path, email))
25
+ k.uses.append(what)
26
+ k.sign |= sign
27
+
28
+ if d.ssh_key:
29
+ use(d.ssh_key, d.email or "", f"{', '.join(d.ssh_hosts) or 'any host'} (default)", d.sign)
30
+ for i in ids:
31
+ if i.ssh_key:
32
+ use(i.ssh_key, i.email, f"{i.host}: {', '.join(i.owners) or '*'} [{i.id}]", signs(i, d))
33
+ elif signs(i, d):
34
+ use(d.ssh_key, i.email, f"signing for [{i.id}]", True)
35
+ return list(keys.values())
36
+
37
+
38
+ def generate(keys: list[Key], expand: Expand, passphrase: bool = True) -> int:
39
+ """Create missing keys; existing ones are never touched. Returns how many were created."""
40
+ created = 0
41
+ for k in keys:
42
+ p = expand(k.path)
43
+ if p.exists():
44
+ continue
45
+ p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
46
+ cmd = ["ssh-keygen", "-t", "ed25519", "-a", "100", "-f", str(p),
47
+ "-C", f"{k.email} {socket.gethostname()}"]
48
+ subprocess.run(cmd + ([] if passphrase else ["-N", ""]), check=True)
49
+ created += 1
50
+ return created
51
+
52
+
53
+ def report(keys: list[Key], expand: Expand) -> None:
54
+ print("SSH keys -- register each public key with the account(s) listed:")
55
+ for k in keys:
56
+ role = "auth + signing" if k.sign else "auth"
57
+ print(f" {k.path} ({role}) -> {'; '.join(k.uses)}")
58
+ pub = pub_path(expand(k.path))
59
+ print(f" {pub.read_text().strip()}" if pub.exists() else " (missing -- run: gitpro keys --generate)")
gitpro/lint.py ADDED
@@ -0,0 +1,50 @@
1
+ """Advisory checks. Never block a run, never write."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import subprocess
6
+
7
+ from . import keys, render
8
+ from .config import Default, Identity
9
+ from .plan import Env
10
+
11
+
12
+ def warnings(d: Default, ids: list[Identity], env: Env) -> list[str]:
13
+ out = []
14
+ for i in ids:
15
+ if i.ssh_alias and i.rewrite:
16
+ out += [f"identity {i.id!r}: owner {o!r} is a glob; insteadOf needs a literal prefix, so "
17
+ f"its URLs aren't rewritten to {i.ssh_alias}" for o in i.owners if "*" in o]
18
+ if i.ssh_key and not i.ssh_alias and i.host in d.ssh_hosts:
19
+ out.append(f"identity {i.id!r}: ssh tries {i.ssh_key} first, then {d.ssh_key} ([default].ssh_hosts); "
20
+ f"if {i.ssh_key} isn't registered you are signed in as the default account. "
21
+ "Add an ssh_alias to offer only this identity's key")
22
+
23
+ gitconfig = env.home / ".gitconfig"
24
+ if gitconfig.exists():
25
+ stray = subprocess.run(["git", "config", "--file", str(gitconfig), "--get-regexp",
26
+ r"^(user\.(name|email|signingkey)|core\.sshcommand)$"],
27
+ capture_output=True, text=True).stdout.strip()
28
+ if stray:
29
+ out.append(f"{gitconfig} also sets these directly; the generated config is included after them "
30
+ "and wins, so they only confuse -- consider removing:\n " + stray.replace("\n", "\n "))
31
+
32
+ ssh_dir = env.home / ".ssh"
33
+ if ssh_dir.exists() and ssh_dir.stat().st_mode & 0o022:
34
+ out.append(f"{ssh_dir} is group/world-writable; ssh may refuse it -- chmod 700 {ssh_dir}")
35
+ ssh_cfg = ssh_dir / "config"
36
+ if ssh_cfg.exists():
37
+ text = render.without_block(ssh_cfg.read_text())
38
+ theirs = {h for line in re.findall(r"^\s*Host\s+(.+)$", text, re.M | re.I) for h in line.split()}
39
+ ours = set(d.ssh_hosts) | {i.ssh_alias for i in ids if i.ssh_alias}
40
+ if ours & theirs:
41
+ out.append(f"{ssh_cfg} also defines Host {' '.join(sorted(ours & theirs))}; "
42
+ "the generated block comes first and wins, remove the old one")
43
+
44
+ for k in keys.inventory(d, ids):
45
+ p = env.expand(k.path)
46
+ if not p.exists():
47
+ out.append(f"{k.path} missing -- run: gitpro keys --generate")
48
+ elif p.stat().st_mode & 0o077:
49
+ out.append(f"{k.path} is accessible by others -- chmod 600 {p}")
50
+ return out