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/cli.py ADDED
@@ -0,0 +1,499 @@
1
+ """Per-remote git identities (name, email, SSH key, commit signing) from one TOML file.
2
+
3
+ Each identity is bound to a host and owners (GitHub users/orgs, GitLab groups);
4
+ git picks it from the repo's remote URL via includeIf hasconfig (git >= 2.36).
5
+ Every command is idempotent: a second 'apply' changes nothing.
6
+
7
+ config file (first match wins):
8
+ -c PATH, $GITPRO_CONFIG, ~/.config/gitpro.toml
9
+
10
+ first run on a new machine:
11
+ gitpro setup --write # guided configuration; confirm before saving
12
+ gitpro plan # review every change as a diff
13
+ gitpro apply -y # explicitly write the reviewed changes
14
+ gitpro keys # public keys to register with each account
15
+ gitpro scan # every repo and the identity git uses there
16
+
17
+ -- for developers (not part of --help) --
18
+ CALLER_UMASK is the umask gitpro was started with. It is None until main() runs; main() saves it and
19
+ then tightens the process umask to 077, so every file gitpro writes is private. A command that creates
20
+ files the user owns in the usual way (clone, init of a work tree) passes umask=caller_umask() to
21
+ subprocess.run. Code that runs outside main() (tests, the hot path, library callers) uses
22
+ caller_umask() as well: it returns the saved value, else hot.CALLER_UMASK, else the current umask,
23
+ read without changing it.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import importlib
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+ from typing import Dict, Iterator, Optional, Tuple
34
+
35
+ from . import __version__, config, jsonout
36
+ from .args import CAPABILITIES, capability, common, confirm, fail, guarded, preview_only, preview_result
37
+
38
+ PROG = "gitpro"
39
+ MIN_GIT = (2, 36)
40
+
41
+ # One line per package, alphabetical by module name. 01 writes the whole list up front; a module that
42
+ # is not built yet is skipped (see _extension_modules). No later package edits this file.
43
+ EXTENSIONS = [
44
+ "gitpro.agents", # 10: agent-check, agent
45
+ "gitpro.audit", # 08: audit
46
+ "gitpro.changes", # approved persistent agent changes
47
+ "gitpro.clone", # 07: clone
48
+ "gitpro.completion", # 16: completion
49
+ "gitpro.devcontainer", # 13: devcontainer
50
+ "gitpro.discover", # 04: discover, init --discover
51
+ "gitpro.doctor", # 16: doctor
52
+ "gitpro.envcmd", # 12: env
53
+ "gitpro.forge", # 05: forge (01 ships the module; 05 adds the command)
54
+ "gitpro.guard", # 06: guard
55
+ "gitpro.health", # 09: auth, keys --health/--upload
56
+ "gitpro.lost", # 03: lost
57
+ "gitpro.mcp", # 11: mcp
58
+ "gitpro.operations", # versioned agent preflight and scoped execution
59
+ "gitpro.policy", # 14: policy, team, check --policy
60
+ "gitpro.prompt", # 16: prompt
61
+ "gitpro.resolve", # 01: why
62
+ "gitpro.scanroots", # 02: scan -c/--roots
63
+ "gitpro.setup", # guided configuration
64
+ ]
65
+
66
+ CALLER_UMASK: Optional[int] = None # the umask gitpro was started with; main() then tightens it to 077
67
+
68
+ Subs = Dict[str, argparse.ArgumentParser]
69
+
70
+
71
+ def caller_umask() -> int:
72
+ """The umask gitpro was started with: CALLER_UMASK once main() (or hot.main()) ran, else the
73
+ current process umask, read without changing it."""
74
+ if CALLER_UMASK is not None:
75
+ return CALLER_UMASK
76
+ from . import hot
77
+ if hot.CALLER_UMASK is not None:
78
+ return hot.CALLER_UMASK
79
+ try:
80
+ with open("/proc/self/status", encoding="ascii") as f:
81
+ for line in f:
82
+ if line.startswith("Umask:"):
83
+ return int(line.split()[1], 8)
84
+ except (OSError, ValueError, IndexError):
85
+ pass
86
+ cur = os.umask(0o077) # no /proc (macOS): the only portable read sets it; restore at once
87
+ os.umask(cur)
88
+ return cur
89
+
90
+
91
+ def _extension_modules() -> Iterator:
92
+ """Import every listed module; skip the ones that do not exist yet. Only a ModuleNotFoundError for
93
+ that exact name is skipped, so a broken import inside a module still fails loudly. Yields modules
94
+ that have register() or extend() (or both)."""
95
+ for name in EXTENSIONS:
96
+ try:
97
+ mod = importlib.import_module(name)
98
+ except ModuleNotFoundError as e:
99
+ if e.name == name:
100
+ continue
101
+ raise
102
+ if hasattr(mod, "register") or hasattr(mod, "extend"):
103
+ yield mod
104
+
105
+
106
+ def extend_command(subs: Subs, name: str, add_flags, handler) -> None:
107
+ """Let a package add flags to an existing command without editing cmd_<name>.
108
+ add_flags(parser) adds the options; handler(args) returns an int to take over, or None to fall
109
+ through to the original command."""
110
+ p = subs[name]
111
+ add_flags(p)
112
+ orig = p.get_default("func")
113
+
114
+ def func(args):
115
+ rc = handler(args)
116
+ return orig(args) if rc is None else rc
117
+ p.set_defaults(func=func)
118
+
119
+
120
+ def build_parser() -> Tuple[argparse.ArgumentParser, Subs]:
121
+ com, conf = common(), confirm()
122
+ doc = __doc__.split("\n-- for developers")[0].rstrip("\n")
123
+ p = argparse.ArgumentParser(prog=PROG, description=doc.split("\n\n")[0],
124
+ epilog="\n\n".join(doc.split("\n\n")[1:]),
125
+ formatter_class=argparse.RawDescriptionHelpFormatter)
126
+ p.add_argument("--version", action="store_true", help="print the version (with --json: and the capabilities)")
127
+ p.add_argument("--json", dest="version_json", action="store_true", help=argparse.SUPPRESS)
128
+ sub = p.add_subparsers(dest="command", metavar="COMMAND")
129
+ sp = sub.add_parser("init", parents=[conf], help="preview a config example (-y creates it; never overwrites)")
130
+ sp.add_argument("path", nargs="?", type=Path, help=f"default: ~/{config.USER_CONFIG}")
131
+ sp.set_defaults(func=cmd_init)
132
+ sub.add_parser("plan", parents=[com], help="show what apply would change, with diffs").set_defaults(func=cmd_plan)
133
+ sub.add_parser("apply", parents=[com, conf], help="write the changes (backs up hand-written files)") \
134
+ .set_defaults(func=cmd_apply)
135
+ sp = sub.add_parser("check", parents=[com], help="exit 1 if apply would change anything (for CI/ansible)")
136
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
137
+ sp.set_defaults(func=cmd_check)
138
+ k = sub.add_parser("keys", parents=[com], help="list SSH keys and the accounts to register them with")
139
+ k.add_argument("--generate", action="store_true", help="create missing keys (ed25519, never overwrites)")
140
+ k.add_argument("--no-passphrase", action="store_true", help="with --generate: empty passphrase (not advised)")
141
+ k.set_defaults(func=cmd_keys)
142
+ sp = sub.add_parser("which", help="show which identity git uses in a repo")
143
+ sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
144
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
145
+ sp.set_defaults(func=cmd_which)
146
+ s = sub.add_parser("scan", parents=[conf], help="list repos and the identity git uses in each")
147
+ s.add_argument("dirs", nargs="*", type=Path, metavar="DIR", help="where to look (default: your home directory)")
148
+ s.add_argument("--fix", action="store_true", help="remove repo-level user.name/user.email that override gitpro")
149
+ s.add_argument("--suggest", action="store_true", help="print [[identity]] blocks that keep each repo's identity")
150
+ s.add_argument("-v", "--verbose", action="store_true", help="full paths, and the remote owners behind each identity")
151
+ s.add_argument("--repo", action="append", type=Path, metavar="PATH",
152
+ help="only this repo, not the repos under it (repeatable)")
153
+ s.add_argument("--json", action="store_true", help="machine-readable output (with --fix: what was removed)")
154
+ s.add_argument("--plain", action="store_true", help="with --fix: a y/N question instead of the interactive list")
155
+ s.set_defaults(func=cmd_scan)
156
+ sp = sub.add_parser("pin", parents=[conf], help="preview pinning the repo identity (-y writes it)")
157
+ sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
158
+ sp.set_defaults(func=cmd_pin)
159
+ sp = sub.add_parser("unpin", parents=[conf], help="preview removing the pin (-y removes it)")
160
+ sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
161
+ sp.set_defaults(func=cmd_unpin)
162
+ u = sub.add_parser("uninstall", parents=[conf],
163
+ help="remove everything apply wrote (keeps your config file and SSH keys)")
164
+ u.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS)
165
+ u.set_defaults(func=cmd_uninstall)
166
+ capability("which.json", "check.json", "dry-run-defaults.v1")
167
+
168
+ mods = list(_extension_modules())
169
+ for mod in mods:
170
+ reg = getattr(mod, "register", None)
171
+ if reg:
172
+ reg(sub, com, conf)
173
+ for mod in mods: # second pass: flags on existing commands
174
+ ext = getattr(mod, "extend", None)
175
+ if ext:
176
+ ext(sub.choices)
177
+ return p, sub.choices
178
+
179
+
180
+ def _env(args: argparse.Namespace):
181
+ from . import plan
182
+ return plan.Env(args.home.expanduser().resolve())
183
+
184
+
185
+ def _load_all(args: argparse.Namespace):
186
+ """(Config, plan.Env) for a command that needs the config; checks the git version first."""
187
+ from . import resolve
188
+ resolve.require_git()
189
+ env = _env(args)
190
+ path, how = config.find(args.config, env.home)
191
+ return config.load_all(path, how), env
192
+
193
+
194
+ def _warn(cfg, env) -> None:
195
+ from . import lint, view
196
+ for msg in lint.warnings(cfg.default, list(cfg.identities), env):
197
+ view.warn(msg)
198
+
199
+
200
+ def _confirm(question: str, yes: bool) -> bool:
201
+ if yes:
202
+ return True
203
+ if not sys.stdin.isatty():
204
+ raise config.ConfigError("no terminal to confirm on; re-run with --yes")
205
+ from . import view
206
+ return view.confirm(question)
207
+
208
+
209
+ def _changes(cfg, env) -> list:
210
+ from . import files, plan
211
+ return files.diff(*plan.build(cfg, env))
212
+
213
+
214
+ def cmd_version(args) -> int:
215
+ if getattr(args, "version_json", False):
216
+ jsonout.emit("version", {"version": __version__, "python": ".".join(map(str, sys.version_info[:3])),
217
+ "capabilities": sorted(CAPABILITIES)})
218
+ else:
219
+ print(f"{PROG} {__version__}")
220
+ return 0
221
+
222
+
223
+ def cmd_status(p: argparse.ArgumentParser, subs: Subs) -> int:
224
+ p.print_help()
225
+ print("\ncommand options (COMMAND -h for details):")
226
+ for s in subs.values():
227
+ print(" " + " ".join(s.format_usage().split()[1:]))
228
+ print()
229
+ try:
230
+ cfg, env = _load_all(argparse.Namespace(config=None, home=Path.home()))
231
+ n = len(_changes(cfg, env))
232
+ except config.ConfigError as e:
233
+ print(f"status: {e}")
234
+ return 0
235
+ state = "up to date" if not n else f"{n} pending change(s) -- review with 'plan', write with 'apply'"
236
+ print(f"status: {cfg.path} ({cfg.how}), {len(cfg.identities)} identities, {state}")
237
+ return 0
238
+
239
+
240
+ def cmd_init(args) -> int:
241
+ from importlib import resources
242
+
243
+ from . import view
244
+ target = (args.path or Path.home() / config.USER_CONFIG).expanduser()
245
+ if target.exists():
246
+ view.ok(f"{target} already exists, left unchanged")
247
+ return 0
248
+ text = resources.files(__package__).joinpath("example.toml").read_text()
249
+ if preview_only(args):
250
+ print(f"Would create {target}:\n{text}")
251
+ return preview_result(args, "init", {"path": str(target), "content": text})
252
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
253
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
254
+ with os.fdopen(fd, "w") as f:
255
+ f.write(text)
256
+ extra = "" if args.path is None else f" -c {target}"
257
+ view.ok(f"created {target}")
258
+ view.step(f"replace the placeholders in it, then run: {PROG} plan{extra}")
259
+ return 0
260
+
261
+
262
+ def cmd_plan(args) -> int:
263
+ from . import files
264
+ cfg, env = _load_all(args)
265
+ print(f"config: {cfg.path} ({cfg.how})")
266
+ files.show(_changes(cfg, env), diffs=True)
267
+ _warn(cfg, env)
268
+ return 0
269
+
270
+
271
+ def cmd_apply(args) -> int:
272
+ from . import files, keys, view
273
+ cfg, env = _load_all(args)
274
+ print(f"config: {cfg.path} ({cfg.how})")
275
+ changes = _changes(cfg, env)
276
+ files.show(changes, diffs=preview_only(args))
277
+ if preview_only(args):
278
+ _warn(cfg, env)
279
+ return preview_result(args, "apply", {})
280
+ if changes:
281
+ if not _confirm(f"apply {len(changes)} change(s)? ('{PROG} plan' shows the diffs)", args.yes):
282
+ view.aborted()
283
+ return 1
284
+ files.apply(changes)
285
+ view.ok(f"applied {len(changes)} change(s)")
286
+ _warn(cfg, env)
287
+ if keys.inventory(cfg.default, list(cfg.identities)):
288
+ view.step(f"SSH keys: '{PROG} keys' lists the public keys to register with each account")
289
+ return 0
290
+
291
+
292
+ def cmd_check(args) -> int:
293
+ from . import files
294
+ cfg, env = _load_all(args)
295
+ changes = _changes(cfg, env)
296
+ if getattr(args, "json", False):
297
+ jsonout.emit("check", {
298
+ "changes": [{"action": c.action, "path": str(c.path),
299
+ "mode": f"{c.mode:04o}" if c.action in ("create", "chmod") else None} for c in changes],
300
+ "up_to_date": not changes})
301
+ else:
302
+ files.show(changes, diffs=False)
303
+ return 1 if changes else 0
304
+
305
+
306
+ def cmd_keys(args) -> int:
307
+ from . import keys
308
+ cfg, env = _load_all(args)
309
+ d, ids = cfg.default, list(cfg.identities)
310
+ inv = keys.inventory(d, ids)
311
+ if not inv:
312
+ print("no SSH keys configured (set ssh_key in [default] or an identity)")
313
+ return 0
314
+ if args.generate and keys.generate(inv, env.expand, passphrase=not args.no_passphrase):
315
+ print(f"keys created -- run '{PROG} apply' so the signing config picks them up")
316
+ keys.report(inv, env.expand)
317
+ for h in d.ssh_hosts:
318
+ print(f"test: ssh -T git@{h}")
319
+ for i in ids:
320
+ if i.ssh_alias:
321
+ print(f"test: ssh -T {i.ssh_user}@{i.ssh_alias}")
322
+ print("first connect: compare the host key fingerprint with the provider's published one")
323
+ return 0
324
+
325
+
326
+ WHICH_KEYS = ("user.name", "user.email", "user.signingkey", "commit.gpgsign", "core.sshcommand")
327
+
328
+
329
+ def cmd_which(args) -> int:
330
+ from . import resolve
331
+ if resolve.is_repo(Path(args.dir)) is None:
332
+ raise config.ConfigError(f"{args.dir}: not a git repository")
333
+ eff = resolve.effective(Path(args.dir))
334
+ settings = dict(zip(WHICH_KEYS, (eff.name, eff.email, eff.signingkey, eff.gpgsign, eff.sshcommand)))
335
+ if getattr(args, "json", False):
336
+ jsonout.emit("which", {
337
+ "repo": str(resolve.is_repo(Path(args.dir))), "remotes": {n: resolve.redact_url(u) for n, u in eff.remotes.items()},
338
+ "settings": {k: {"value": s.value, "origin": s.origin, "scope": s.scope} for k, s in settings.items()}})
339
+ return 0
340
+ print("remotes:" + ("".join(f"\n remote.{n}.url {resolve.redact_url(u)}" for n, u in eff.remotes.items()) if eff.remotes
341
+ else " (none)"))
342
+ for key, s in settings.items():
343
+ origin = s.origin or ""
344
+ if origin.startswith("file:"):
345
+ origin = "file:" + resolve._tilde(origin[5:])
346
+ print(f" {key:16} {s.value if s.value is not None else '(unset)'}" + (f" [{origin}]" if origin else ""))
347
+ return 0
348
+
349
+
350
+ def cmd_scan(args) -> int:
351
+ from . import plan, scan, view
352
+ home = Path.home().resolve()
353
+ managed = str(home / plan.MANAGED_GITCONFIG)
354
+
355
+ def show(p) -> str:
356
+ p = Path(p)
357
+ return "~" if p == home else plan.Env(home).tilde(p)
358
+
359
+ def label(src: str) -> str:
360
+ return "[default]" if src == managed else show(src) if src.startswith("/") else src
361
+
362
+ ask = args.fix and not args.yes and not args.json
363
+ if ask and not sys.stdin.isatty():
364
+ raise config.ConfigError("no terminal to confirm on; re-run with --yes")
365
+ # scanroots (02) sets roots_effective, exclude, roots_source and roots_hint; --repo ignores them.
366
+ exclude, excluded = getattr(args, "exclude", ()), set()
367
+ if args.repo:
368
+ roots = [p.expanduser().resolve() for p in args.repo]
369
+ paths = roots
370
+ else:
371
+ roots = getattr(args, "roots_effective", None) or [d.expanduser().resolve() for d in args.dirs] or [home]
372
+ paths = scan.find(roots, exclude=exclude, excluded=excluded)
373
+ repos, failed = scan.inspect_all(paths)
374
+ rep = scan.report(roots, repos, failed, label)
375
+ rep.update(exclude=list(exclude), skipped_excluded=len(excluded),
376
+ roots_source=getattr(args, "roots_source", "args" if args.repo or args.dirs else "default"))
377
+ fixable = [r for r in repos if any(k in r.beneath for k in r.local)]
378
+
379
+ if ask and fixable and not args.plain and sys.stdout.isatty():
380
+ try:
381
+ from . import tui
382
+ except ImportError: # the optional textual extra is not installed
383
+ pass
384
+ else:
385
+ chosen = tui.choose(rep, show)
386
+ if not chosen:
387
+ view.aborted()
388
+ return 1
389
+ return _fix(args, [r for r in fixable if str(r.path) in chosen], home)
390
+
391
+ if args.json and not args.fix:
392
+ jsonout.emit("scan", rep)
393
+ return 0
394
+ if not args.json:
395
+ where = "".join(f" --repo {_sh(show(p))}" for p in roots) if args.repo else \
396
+ "".join(f" {_sh(show(d))}" for d in roots if args.dirs)
397
+ view.scan(rep, show, verbose=args.verbose, suggest=args.suggest, fix=args.fix, prog=PROG,
398
+ again=f"{PROG} scan --fix{where}", exact=bool(args.repo))
399
+ if getattr(args, "roots_hint", None) and not args.fix:
400
+ print()
401
+ view.step(args.roots_hint)
402
+ if not args.fix:
403
+ return 0
404
+ if not fixable:
405
+ if args.json:
406
+ jsonout.emit("scan", {"removed": [], "undo": None})
407
+ else:
408
+ print("\n✓ nothing to fix")
409
+ return 0
410
+ n = sum(k in r.beneath for r in fixable for k in r.local)
411
+ if ask:
412
+ print()
413
+ if not _confirm(f"Remove {view.plural(n, 'setting')} from {view.plural(len(fixable), 'repo')}?", False):
414
+ view.aborted()
415
+ return 1
416
+ return _fix(args, fixable, home)
417
+
418
+
419
+ def _sh(path: str) -> str:
420
+ """Quote a path for the shell, leaving a leading ~ unquoted so the shell still expands it."""
421
+ import shlex
422
+ return path if path == "~" else "~/" + shlex.quote(path[2:]) if path.startswith("~/") else shlex.quote(path)
423
+
424
+
425
+ def _fix(args, repos: list, home: Path) -> int:
426
+ from . import plan, scan
427
+ state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / PROG
428
+ pairs, undo = scan.fix(repos, state, PROG)
429
+ if args.json:
430
+ jsonout.emit("scan", {"removed": [{"path": str(r.path), "key": k, "value": r.local[k]} for r, k in pairs],
431
+ "undo": str(undo) if undo else None})
432
+ elif pairs:
433
+ from . import view
434
+ env = plan.Env(home)
435
+ view.fixed([(env.tilde(r.path), k, r.local[k], r.beneath[k]) for r, k in pairs], env.tilde(undo))
436
+ return 0
437
+
438
+
439
+ def cmd_pin(args) -> int:
440
+ from . import pin, view
441
+ name, email, path = pin.pin(args.dir, dry_run=preview_only(args))
442
+ if preview_only(args):
443
+ print(f"Would pin {name} <{email}> in {path}")
444
+ return preview_result(args, "pin", {})
445
+ view.ok(f"pinned {name} <{email}> in {path}")
446
+ got = subprocess.run(["git", "-C", args.dir, "config", "--show-origin", "--get", "user.email"],
447
+ capture_output=True, text=True).stdout.strip()
448
+ if not got.endswith("\t" + email):
449
+ view.warn(f"git resolves {got!r}; a repo-local setting overrides the pin")
450
+ view.step("signing and SSH key selection are not pinned; re-run after changing the config")
451
+ return 0
452
+
453
+
454
+ def cmd_unpin(args) -> int:
455
+ from . import pin, view
456
+ changed = pin.unpin(args.dir, dry_run=preview_only(args))
457
+ if preview_only(args):
458
+ print("Would remove the pin" if changed else "Not pinned, nothing to do")
459
+ return preview_result(args, "unpin", {})
460
+ view.ok("unpinned" if changed else "not pinned, nothing to do")
461
+ return 0
462
+
463
+
464
+ def cmd_uninstall(args) -> int:
465
+ from . import files, plan, view
466
+ changes = files.diff(*plan.teardown(_env(args)))
467
+ files.show(changes, diffs=preview_only(args))
468
+ if preview_only(args):
469
+ return preview_result(args, "uninstall", {})
470
+ if not changes:
471
+ return 0
472
+ if not _confirm(f"undo {len(changes)} change(s)?", args.yes):
473
+ view.aborted()
474
+ return 1
475
+ files.apply(changes)
476
+ plan.prune_hooks_dir(_env(args)) # 06: the guard's hooks dir, when empty
477
+ view.ok(f"removed {len(changes)} change(s); your config file and SSH keys were left in place")
478
+ return 0
479
+
480
+
481
+ def _fail(msg: str, args) -> int:
482
+ return fail(msg, args)
483
+
484
+
485
+ def main(argv: Optional[list] = None) -> int:
486
+ global CALLER_UMASK
487
+ if os.name != "posix":
488
+ print(f"✗ {PROG} supports Linux and macOS only", file=sys.stderr)
489
+ return 2
490
+ p, subs = build_parser()
491
+ args = p.parse_args(argv)
492
+ if args.version:
493
+ return cmd_version(args)
494
+ if os.geteuid() == 0:
495
+ return _fail("run as the user who owns these identities, not root", args)
496
+ CALLER_UMASK = os.umask(0o077)
497
+ if args.command is None:
498
+ return guarded(lambda a: cmd_status(p, subs), args)
499
+ return guarded(args.func, args)