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/prompt.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""`gitpro prompt`: a shell prompt segment (`✓ work-github`, `✗ old@personal.test`), cheap enough for every prompt.
|
|
2
|
+
|
|
3
|
+
A hot module (hot.HOT): module level imports only the standard library and args; config and resolve load
|
|
4
|
+
on a cache miss (a hit costs a few stat calls on top of Python's start-up); never cli, view or rich. A cache in the state dir, keyed by the repo's git dir, holds the last answer plus the
|
|
5
|
+
(path, size, mtime_ns, inode) of every file that can change it and a hash of the identity env vars. A hit
|
|
6
|
+
needs no git call and no TOML parse; a miss runs resolve.compare (three git calls) and rewrites the cache.
|
|
7
|
+
Prints one line or nothing, and always exits 0.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
from . import args as gargs
|
|
21
|
+
|
|
22
|
+
CACHE_NAME = "prompt-cache.json"
|
|
23
|
+
CACHE_VERSION = 1
|
|
24
|
+
MAX_ENTRIES = 200
|
|
25
|
+
DEFAULT_MAX_MS = 80
|
|
26
|
+
SYMBOLS = {"ok": "✓", "mismatch": "✗", "warn": "⚠", "none": "⚠"}
|
|
27
|
+
# Any of these makes the answer depend on things gitpro cannot stat cheaply: no cache read or write.
|
|
28
|
+
BYPASS_VARS = ("GIT_CONFIG_COUNT", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_PARAMETERS")
|
|
29
|
+
ENV_PREFIXES = ("GIT_AUTHOR_", "GIT_COMMITTER_", "GIT_CONFIG_")
|
|
30
|
+
CONFIG_ENV_VAR = "GITPRO_CONFIG" # config.ENV_VAR
|
|
31
|
+
USER_CONFIG = ".config/gitpro.toml" # config.USER_CONFIG
|
|
32
|
+
PIN_FILE = "git-identities" # resolve.PIN_FILE
|
|
33
|
+
MANAGED_GITCONFIG = ".gitconfig-identities" # resolve.MANAGED_GITCONFIG
|
|
34
|
+
ENV_NAMES = ("HOME", CONFIG_ENV_VAR)
|
|
35
|
+
# resolve problem codes that leave the email right: `⚠ id` (state "warn"); every other code is `✗ email`.
|
|
36
|
+
WARN_CODES = ("key_mismatch", "not_applied", "sign_mismatch", "policy")
|
|
37
|
+
|
|
38
|
+
INSTALL_HELP = """\
|
|
39
|
+
bash: PS1='$(gitpro prompt) '"$PS1"
|
|
40
|
+
zsh: autoload -Uz add-zsh-hook; _gitpro_prompt() { psvar[1]=$(gitpro prompt); }; add-zsh-hook precmd _gitpro_prompt; PS1='%1v '"$PS1"
|
|
41
|
+
fish: function fish_prompt; gitpro prompt; echo -n ' '; prompt_pwd; echo -n ' > '; end
|
|
42
|
+
starship: [custom.gitpro] command = "gitpro prompt" when = "git rev-parse --git-dir" shell = ["sh"]
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
Stat = List[object] # [path, size, mtime_ns, inode]; the last three are None for a missing file
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------- finding the repo without git
|
|
49
|
+
|
|
50
|
+
def _read_line(path: Path) -> str:
|
|
51
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
52
|
+
return f.readline().strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _git_dirs(start: Path) -> Optional[Tuple[Path, Path]]:
|
|
56
|
+
"""(git dir, common dir) of the repo holding start, found by walking up like git does; None outside
|
|
57
|
+
a repo. Reads at most two small files; never runs git."""
|
|
58
|
+
d = start.resolve()
|
|
59
|
+
for cand in (d, *d.parents):
|
|
60
|
+
dot = cand / ".git"
|
|
61
|
+
if dot.is_dir():
|
|
62
|
+
git_dir = dot
|
|
63
|
+
elif dot.is_file():
|
|
64
|
+
line = _read_line(dot)
|
|
65
|
+
if not line.startswith("gitdir:"):
|
|
66
|
+
return None
|
|
67
|
+
git_dir = Path(os.path.normpath(cand / line[len("gitdir:"):].strip()))
|
|
68
|
+
elif (cand / "HEAD").is_file() and (cand / "objects").is_dir() and (cand / "refs").is_dir():
|
|
69
|
+
git_dir = cand # inside a git dir, or a bare repo
|
|
70
|
+
else:
|
|
71
|
+
continue
|
|
72
|
+
common = git_dir
|
|
73
|
+
if (git_dir / "commondir").is_file():
|
|
74
|
+
common = Path(os.path.normpath(git_dir / _read_line(git_dir / "commondir")))
|
|
75
|
+
return git_dir, common
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- the cache key
|
|
80
|
+
|
|
81
|
+
def _stat(path: object) -> Stat:
|
|
82
|
+
try:
|
|
83
|
+
st = os.stat(str(path))
|
|
84
|
+
except OSError:
|
|
85
|
+
return [str(path), None, None, None]
|
|
86
|
+
return [str(path), st.st_size, st.st_mtime_ns, st.st_ino]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _xdg_git_config(home: Path) -> Path:
|
|
90
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") / "git" / "config"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _head_inputs(git_dir: Path, common: Path, cfg_path: Path, home: Path) -> List[str]:
|
|
94
|
+
"""The inputs known before the TOML is read; the identity files follow them in the stored key."""
|
|
95
|
+
return [str(p) for p in (cfg_path, common / "config", git_dir / "config.worktree", common / PIN_FILE,
|
|
96
|
+
home / ".gitconfig", home / MANAGED_GITCONFIG, _xdg_git_config(home))]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cache_key(git_dir: Path, common: Path, cfg_path: Path, home: Path, extra: Sequence[object] = ()) -> List[Stat]:
|
|
100
|
+
"""[(path, size, mtime_ns, inode)] of every file that can change the answer: the TOML, .git/config,
|
|
101
|
+
config.worktree, the pin file, ~/.gitconfig, ~/.gitconfig-identities, git's XDG config, and `extra`
|
|
102
|
+
(each identity's ~/.gitconfig-<id>)."""
|
|
103
|
+
return [_stat(p) for p in [*_head_inputs(git_dir, common, cfg_path, home), *map(str, extra)]]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def env_hash(env: Optional[Dict[str, str]] = None) -> str:
|
|
107
|
+
"""sha256 of the env vars that affect identity (never stored in clear)."""
|
|
108
|
+
env = os.environ if env is None else env
|
|
109
|
+
items = sorted((k, v) for k, v in env.items() if k.startswith(ENV_PREFIXES) or k in ENV_NAMES)
|
|
110
|
+
return hashlib.sha256(json.dumps(items).encode()).hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cache_bypassed(env: Optional[Dict[str, str]] = None) -> bool:
|
|
114
|
+
env = os.environ if env is None else env
|
|
115
|
+
return any(env.get(v) for v in BYPASS_VARS)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cache_path(home: Optional[Path] = None) -> Path:
|
|
119
|
+
base = os.environ.get("XDG_STATE_HOME") or str((home or Path.home()) / ".local/state")
|
|
120
|
+
return Path(base) / "gitpro" / CACHE_NAME
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_cache(path: Path) -> dict:
|
|
124
|
+
"""The cache's entries; {} when the file is missing, unreadable or the wrong shape."""
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
return {}
|
|
129
|
+
if not isinstance(data, dict) or data.get("version") != CACHE_VERSION or not isinstance(data.get("entries"), dict):
|
|
130
|
+
return {}
|
|
131
|
+
return {k: v for k, v in data["entries"].items() if _valid_entry(v)}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _valid_entry(e: object) -> bool:
|
|
135
|
+
return (isinstance(e, dict) and isinstance(e.get("key"), list) and isinstance(e.get("env"), str)
|
|
136
|
+
and isinstance(e.get("result"), dict)
|
|
137
|
+
and isinstance(e.get("t"), (int, float)) and not isinstance(e.get("t"), bool)
|
|
138
|
+
and all(isinstance(k, list) and len(k) == 4 and isinstance(k[0], str) for k in e["key"]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _save_cache(path: Path, entries: dict) -> None:
|
|
142
|
+
from .files import atomic_write
|
|
143
|
+
if len(entries) > MAX_ENTRIES:
|
|
144
|
+
keep = sorted(entries, key=lambda k: entries[k].get("t", 0))[-MAX_ENTRIES:]
|
|
145
|
+
entries = {k: entries[k] for k in keep}
|
|
146
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
147
|
+
atomic_write(path, json.dumps({"version": CACHE_VERSION, "entries": entries}, ensure_ascii=False), 0o600)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _hit(entry: dict, head: List[str], env: str) -> bool:
|
|
151
|
+
key = entry["key"]
|
|
152
|
+
return (entry["env"] == env and [k[0] for k in key[:len(head)]] == head
|
|
153
|
+
and all(_stat(k[0]) == k for k in key))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------- the answer
|
|
157
|
+
|
|
158
|
+
def find_config(explicit: Optional[Path], home: Path) -> Path:
|
|
159
|
+
"""config.find without importing config (same order: -c, $GITPRO_CONFIG, ~/.config/gitpro.toml)."""
|
|
160
|
+
if explicit:
|
|
161
|
+
return Path(explicit).expanduser()
|
|
162
|
+
if os.environ.get(CONFIG_ENV_VAR):
|
|
163
|
+
return Path(os.environ[CONFIG_ENV_VAR]).expanduser()
|
|
164
|
+
if (home / USER_CONFIG).is_file():
|
|
165
|
+
return home / USER_CONFIG
|
|
166
|
+
from . import config
|
|
167
|
+
return config.find(None, home)[0] # raises the usual "no config found"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _compute(repo: Path, cfg_path: Path) -> Tuple[dict, List[str]]:
|
|
171
|
+
"""(result, identity files) for repo: three git calls through resolve.compare."""
|
|
172
|
+
from . import config, resolve
|
|
173
|
+
cfg = config.load_all(cfg_path)
|
|
174
|
+
rep = resolve.compare(repo, cfg)
|
|
175
|
+
exp, eff = rep.expected, rep.effective
|
|
176
|
+
key = resolve.ssh_key_of(eff.sshcommand.value) or exp.ssh_key
|
|
177
|
+
if exp.email is None:
|
|
178
|
+
state = "none" # spec test 10: `⚠ no identity`
|
|
179
|
+
elif (eff.email.value or "").lower() != exp.email.lower() or \
|
|
180
|
+
any(p.code not in WARN_CODES for p in rep.problems):
|
|
181
|
+
state = "mismatch" # git would (or might) commit as the wrong email
|
|
182
|
+
else:
|
|
183
|
+
state = "warn" if rep.problems else "ok" # right email, something else off (key, sign, not applied...)
|
|
184
|
+
result = {"id": exp.id or ("default" if exp.email else ""), "email": eff.email.value or "",
|
|
185
|
+
"key": os.path.basename(key) if key else "", "state": state}
|
|
186
|
+
files = [os.path.expanduser(i.file) for i in cfg.identities if i.file]
|
|
187
|
+
return result, files
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def render(result: dict, fmt: Optional[str]) -> str:
|
|
191
|
+
"""The segment text for a cached or computed result."""
|
|
192
|
+
state = result.get("state", "none")
|
|
193
|
+
vals = {"id": result.get("id", ""), "email": result.get("email", ""), "key": result.get("key", ""),
|
|
194
|
+
"state": state, "sym": SYMBOLS.get(state, "⚠")}
|
|
195
|
+
if fmt is not None:
|
|
196
|
+
return fmt.format(**vals)
|
|
197
|
+
if state == "ok":
|
|
198
|
+
return f"{vals['sym']} {vals['id']}"
|
|
199
|
+
if state == "mismatch":
|
|
200
|
+
return f"{vals['sym']} {vals['email'] or '(no email)'}"
|
|
201
|
+
if state == "warn":
|
|
202
|
+
return f"{vals['sym']} {vals['id']}"
|
|
203
|
+
return f"{vals['sym']} no identity"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def segment(dir: object = ".", fmt: Optional[str] = None, max_ms: int = DEFAULT_MAX_MS, *,
|
|
207
|
+
config_path: Optional[Path] = None, home: Optional[Path] = None) -> str:
|
|
208
|
+
"""The prompt text for the repo holding dir ("" outside a repo, or over budget with nothing cached).
|
|
209
|
+
Raises on errors; main() turns every error into empty output."""
|
|
210
|
+
home = Path(home) if home else Path.home()
|
|
211
|
+
found = _git_dirs(Path(dir))
|
|
212
|
+
if found is None:
|
|
213
|
+
return ""
|
|
214
|
+
git_dir, common = found
|
|
215
|
+
cfg_path = find_config(config_path, home)
|
|
216
|
+
use_cache = not cache_bypassed()
|
|
217
|
+
cpath = cache_path(home)
|
|
218
|
+
entries = _load_cache(cpath) if use_cache else {}
|
|
219
|
+
slot = str(git_dir)
|
|
220
|
+
old = entries.get(slot)
|
|
221
|
+
head = _head_inputs(git_dir, common, cfg_path, home)
|
|
222
|
+
env = env_hash()
|
|
223
|
+
if old is not None and _hit(old, head, env):
|
|
224
|
+
return render(old["result"], fmt)
|
|
225
|
+
|
|
226
|
+
start = time.monotonic()
|
|
227
|
+
key_before = cache_key(git_dir, common, cfg_path, home)
|
|
228
|
+
result, files = _compute(Path(dir), cfg_path)
|
|
229
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
230
|
+
if use_cache:
|
|
231
|
+
# Stat before computing, so an edit made while git ran invalidates this entry next time.
|
|
232
|
+
key = key_before + [_stat(f) for f in files]
|
|
233
|
+
entries[slot] = {"key": key, "env": env, "result": result, "t": time.time()}
|
|
234
|
+
try:
|
|
235
|
+
_save_cache(cpath, entries)
|
|
236
|
+
except OSError: # read-only or broken state dir: still answer, just uncached
|
|
237
|
+
pass
|
|
238
|
+
if elapsed_ms > max_ms:
|
|
239
|
+
return render(old["result"], fmt) if old is not None else ""
|
|
240
|
+
return render(result, fmt)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------- command
|
|
244
|
+
|
|
245
|
+
def add_arguments(p: argparse.ArgumentParser) -> None:
|
|
246
|
+
p.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
|
|
247
|
+
p.add_argument("--format", dest="fmt", metavar="FMT",
|
|
248
|
+
help='placeholders {id} {email} {key} {state} {sym} (default: "{sym} {id}", '
|
|
249
|
+
"the effective email on a mismatch)")
|
|
250
|
+
p.add_argument("--max-ms", type=int, default=DEFAULT_MAX_MS, metavar="N",
|
|
251
|
+
help=f"print nothing (or the last cached value) when git takes longer (default: {DEFAULT_MAX_MS})")
|
|
252
|
+
p.add_argument("--install-help", action="store_true", help="print the lines to add to your shell's prompt")
|
|
253
|
+
p.set_defaults(func=cmd_prompt)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _write(text: str) -> None:
|
|
257
|
+
"""Write text to stdout; an unencodable string or a closed pipe prints nothing (never raises)."""
|
|
258
|
+
try:
|
|
259
|
+
sys.stdout.write(text)
|
|
260
|
+
sys.stdout.flush()
|
|
261
|
+
except (OSError, UnicodeError, ValueError):
|
|
262
|
+
try: # stop the interpreter's own flush at exit from failing again
|
|
263
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
264
|
+
except (OSError, ValueError, AttributeError):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cmd_prompt(args: argparse.Namespace) -> int:
|
|
269
|
+
if args.install_help:
|
|
270
|
+
_write(INSTALL_HELP)
|
|
271
|
+
return 0
|
|
272
|
+
try:
|
|
273
|
+
text = segment(args.dir, args.fmt, args.max_ms, config_path=args.config, home=args.home)
|
|
274
|
+
except (Exception, KeyboardInterrupt): # a prompt never shows a traceback: empty output, exit 0
|
|
275
|
+
text = ""
|
|
276
|
+
if text:
|
|
277
|
+
_write(text + "\n")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def register(sub, common, confirm) -> None:
|
|
282
|
+
add_arguments(sub.add_parser("prompt", parents=[common], help="shell prompt segment: ✓ id or ✗ wrong email"))
|
|
283
|
+
gargs.capability("prompt")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _BadArgs(Exception):
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _QuietParser(argparse.ArgumentParser):
|
|
291
|
+
"""A usage error prints nothing (a prompt must never print usage text into PS1)."""
|
|
292
|
+
|
|
293
|
+
def error(self, message):
|
|
294
|
+
raise _BadArgs(message)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main(argv: Sequence[str]) -> int:
|
|
298
|
+
"""Always 0: bad arguments, errors and Ctrl-C print nothing (--help still prints help)."""
|
|
299
|
+
p = _QuietParser(prog="gitpro prompt", parents=[gargs.common()])
|
|
300
|
+
add_arguments(p)
|
|
301
|
+
try:
|
|
302
|
+
return cmd_prompt(p.parse_args(list(argv)[1:]))
|
|
303
|
+
except (_BadArgs, KeyboardInterrupt):
|
|
304
|
+
return 0
|