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