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