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