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/setup.py ADDED
@@ -0,0 +1,260 @@
1
+ """Guided, offline configuration: save the TOML, never apply Git or SSH changes."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import stat
9
+ import sys
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Callable, Optional, Tuple
13
+
14
+ from . import config
15
+ from .config import ConfigError, check
16
+
17
+ Snapshot = Optional[Tuple[bytes, os.stat_result]]
18
+
19
+
20
+ def register(sub, common, confirm) -> None:
21
+ from .args import capability
22
+ p = sub.add_parser("setup", parents=[common], help="configure identities with a guided terminal wizard")
23
+ p.add_argument("--write", action="store_true", help="allow saving after interactive review (default: preview only)")
24
+ p.set_defaults(func=cmd_setup)
25
+ capability("setup")
26
+
27
+
28
+ def _snapshot(path: Path) -> Snapshot:
29
+ try:
30
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
31
+ except FileNotFoundError:
32
+ return None
33
+ except OSError as e:
34
+ raise ConfigError(f"cannot open {path}: {e}; setup requires a regular file, not a symlink") from None
35
+ with os.fdopen(fd, "rb") as f:
36
+ info = os.fstat(f.fileno())
37
+ check(stat.S_ISREG(info.st_mode), f"{path}: setup requires a regular file")
38
+ check(info.st_uid == os.getuid() and not info.st_mode & 0o022,
39
+ f"{path}: must be owned by you and not group/world-writable")
40
+ return f.read(), info
41
+
42
+
43
+ def _same(before: Snapshot, after: Snapshot) -> bool:
44
+ if before is None or after is None:
45
+ return before is after
46
+ a, b = before[1], after[1]
47
+ return before[0] == after[0] and (a.st_dev, a.st_ino, a.st_mode, a.st_mtime_ns) == (
48
+ b.st_dev, b.st_ino, b.st_mode, b.st_mtime_ns)
49
+
50
+
51
+ def _save(path: Path, text: str, before: Snapshot) -> Optional[Path]:
52
+ from . import files
53
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
54
+ lock = path.with_name(path.name + ".setup.lock")
55
+ try:
56
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
57
+ except FileExistsError:
58
+ raise ConfigError(f"{lock}: another setup may be saving; check it before retrying") from None
59
+ os.close(fd)
60
+ try:
61
+ check(_same(before, _snapshot(path)), f"{path}: changed during setup; nothing overwritten, run setup again")
62
+ backup = files._backup(path, before[0]) if before is not None else None
63
+ check(_same(before, _snapshot(path)), f"{path}: changed while backing up; nothing overwritten")
64
+ files.atomic_write(path, text.encode("utf-8"), 0o600, follow_symlinks=False,
65
+ overwrite=before is not None)
66
+ return backup
67
+ finally:
68
+ lock.unlink()
69
+
70
+
71
+ def _block(table: str, values: dict) -> str:
72
+ return table + "\n" + "".join(f"{k} = {json.dumps(v, ensure_ascii=False)}\n" for k, v in values.items())
73
+
74
+
75
+ def _suggestions(home: Path) -> dict:
76
+ from . import resolve, view
77
+ result = {}
78
+ for field in ("name", "email"):
79
+ r = resolve._git(home, "config", "--global", "--get", "user." + field)
80
+ if r.returncode == 0:
81
+ value = r.stdout.strip()
82
+ if value and not config.CONTROL_RE.search(value):
83
+ result[field] = value
84
+ elif r.returncode != 1:
85
+ view.warn(f"could not suggest {field} from Git config: {r.stderr.strip()}")
86
+ repo = resolve.is_repo(Path.cwd())
87
+ if repo:
88
+ remotes = resolve.remotes(repo)
89
+ url = remotes.get("origin") or next(iter(remotes.values()), "")
90
+ host, owner, _ = resolve.parse_url(url)
91
+ if host and config.HOST_RE.fullmatch(host):
92
+ result["host"] = host
93
+ if owner and config.OWNER_RE.fullmatch(owner):
94
+ result["owners"] = owner
95
+ return result
96
+
97
+
98
+ def _validate(text: str, path: Path, home: Path) -> config.Config:
99
+ from . import plan
100
+ cfg = config.loads(text, path, "--config")
101
+ env = plan.Env(home)
102
+ targets, _ = plan.build(cfg, env)
103
+ reserved = [t.path for t in targets] + plan.guard_paths(env) + [home / plan.ALLOWED_SIGNERS]
104
+ source = path.resolve()
105
+ for output in reserved:
106
+ target = output.resolve()
107
+ check(source != target and source not in target.parents and target not in source.parents,
108
+ f"config {path} conflicts with managed output {output}; choose another config path")
109
+ return cfg
110
+
111
+
112
+ def cmd_setup(args) -> int:
113
+ from rich.prompt import Confirm, Prompt
114
+ from rich.syntax import Syntax
115
+ from rich.text import Text
116
+ from . import resolve, view
117
+
118
+ check(sys.stdin.isatty() and sys.stdout.isatty(),
119
+ "setup needs an interactive terminal; run gitpro setup there, or use gitpro init to create an example")
120
+ resolve.require_git()
121
+ home = args.home.expanduser().resolve()
122
+ path = Path(args.config or os.environ.get(config.ENV_VAR) or home / config.USER_CONFIG).expanduser().absolute()
123
+ before = _snapshot(path)
124
+ original = before[0].decode("utf-8") if before else ""
125
+ template = resources.files("gitpro").joinpath("example.toml").read_text(encoding="utf-8")
126
+ from_example = bool(original) and config._load_toml(original, path) == config._load_toml(template)
127
+ cfg = config.loads(original, path) if original and not from_example else None
128
+ _validate(original if cfg is not None else "[default]\n", path, home)
129
+ text = original if cfg is not None else (
130
+ "# Created with gitpro setup.\n"
131
+ "# Reference: https://github.com/Yantrakar/gitpro/blob/main/docs/guide.md\n\n")
132
+ out = view.console()
133
+ out.print(Text("gitpro setup", style="bold"))
134
+ out.print("Configure once, then let Git pick the identity for each remote.")
135
+ out.print(Text(f"Config: {path}"))
136
+ out.print("Offline. No keys are created, no accounts are contacted, and no Git/SSH settings are applied.")
137
+ out.print("Ctrl-C cancels the wizard before you confirm Save.\n")
138
+ if cfg is not None:
139
+ out.print("Existing settings and comments will be kept. This wizard adds identities; it does not edit them.")
140
+ for identity in cfg.identities:
141
+ scope = ", ".join(identity.owners) or "all owners"
142
+ out.print(Text(f" {identity.id}: {identity.name} <{identity.email}> on {identity.host} ({scope})"))
143
+ elif from_example:
144
+ out.print("This is the unchanged example template. Saving will replace its placeholders and keep a backup.")
145
+ suggested = _suggestions(home)
146
+ if cfg:
147
+ person_default = cfg.default if cfg.default.email else next(iter(cfg.identities), None)
148
+ if person_default:
149
+ suggested.setdefault("name", person_default.name)
150
+ suggested.setdefault("email", person_default.email)
151
+
152
+ def ask(label: str, default: str = "", validate: Optional[Callable[[str], None]] = None) -> str:
153
+ while True:
154
+ value = Prompt.ask(Text(label), default=default, show_default=bool(default), console=out).strip()
155
+ try:
156
+ config._no_control_chars(value, label)
157
+ if validate:
158
+ validate(value)
159
+ return value
160
+ except ConfigError as e:
161
+ out.print(Text(str(e), style="red"))
162
+
163
+ def person() -> dict:
164
+ name = ask("Commit name", suggested.get("name", ""),
165
+ lambda v: check(v and "<" not in v and ">" not in v, "Enter a name without < or >"))
166
+ email = ask("Commit email", suggested.get("email", ""),
167
+ lambda v: config._check_person(config.Default(name=name, email=v), "identity", True))
168
+ suggested.update(name=name, email=email)
169
+ return {"name": name, "email": email}
170
+
171
+ if cfg is None:
172
+ out.print("A fallback identity applies to repositories that do not match a host/owner rule.")
173
+ fallback = person() if Confirm.ask("Set a fallback identity?", default=False, console=out) else {}
174
+ text += _block("[default]", fallback)
175
+ cfg = config.loads(text, path)
176
+
177
+ ids = {i.id for i in cfg.identities}
178
+ added = 0
179
+ while Confirm.ask("Add an identity for a Git host?", default=not added, console=out):
180
+ while True:
181
+ proposed_id = "work"
182
+ n = 2
183
+ while proposed_id in ids:
184
+ proposed_id, n = f"work-{n}", n + 1
185
+ ident = ask("Identity label (for example work or personal)", proposed_id,
186
+ lambda v: check(config.ID_RE.fullmatch(v) and v not in ids, "Use a unique identity label"))
187
+ values = {"id": ident, **person()}
188
+ host = ask("Git host", suggested.get("host", "github.com"),
189
+ lambda v: config._check_host(v, "Git host"))
190
+ while True:
191
+ owners = ask("Owners or groups, comma-separated (blank means the entire host)",
192
+ suggested.get("owners", "") if host == suggested.get("host") else "",
193
+ lambda v: check(all(config.OWNER_RE.fullmatch(s.strip()) for s in v.split(","))
194
+ if v else True, "Enter valid, comma-separated owners or groups"))
195
+ if owners or Confirm.ask(f"Match EVERY repository on {host}?", default=False, console=out):
196
+ break
197
+ values.update(host=host, owners=list(dict.fromkeys(s.strip() for s in owners.split(",") if s.strip())))
198
+ public_keys = sorted(p for p in (home / ".ssh").glob("*.pub") if p.is_file())
199
+ if public_keys:
200
+ out.print("Existing SSH keys (only public-key filenames are inspected):")
201
+ for pub in public_keys:
202
+ out.print(Text(f" {pub.with_suffix('')}"))
203
+
204
+ def key_path(v: str) -> None:
205
+ if v:
206
+ config._check_path(v, "SSH key")
207
+ key = home / v[2:] if v.startswith("~/") else Path(v)
208
+ check(not v.endswith(".pub") and key.is_file(), "Choose an existing private-key path, not .pub")
209
+
210
+ key = ask("SSH key path (blank keeps your existing authentication)", validate=key_path)
211
+ if key:
212
+ values["ssh_key"] = key
213
+ alias = "gitpro-" + (re.sub(r"[^a-z0-9-]", "-", ident.lower()).strip("-") or "identity")
214
+ values["ssh_alias"] = ask("SSH alias for this identity", alias,
215
+ lambda v: check(config.HOST_RE.fullmatch(v) and v != host,
216
+ "Choose an SSH alias different from the Git host"))
217
+ values["sign"] = Confirm.ask("Sign commits with this SSH key?", default=False, console=out)
218
+ if values["sign"]:
219
+ private = home / key[2:] if key.startswith("~/") else Path(key)
220
+ if not Path(str(private) + ".pub").is_file():
221
+ out.print("The matching .pub file is missing. Choose a key with a public key to enable signing.")
222
+ continue
223
+ else:
224
+ values["sign"] = False
225
+ block = "\n\n" + _block("[[identity]]", values)
226
+ try:
227
+ cfg = _validate(text + block, path, home)
228
+ except ConfigError as e:
229
+ out.print(Text(f"{e}\nPlease enter this identity again; nothing has been saved.", style="red"))
230
+ continue
231
+ text += block
232
+ ids.add(ident)
233
+ added += 1
234
+ break
235
+ if text == original:
236
+ view.ok("configuration left unchanged")
237
+ return 0
238
+ if cfg.default.email is None and not cfg.identities:
239
+ view.aborted()
240
+ return 1
241
+ _validate(text, path, home)
242
+ out.print(Text("\nReview the configuration additions" if original and not from_example else
243
+ "\nReview your configuration", style="bold"))
244
+ preview = text[len(original):] if original and not from_example else text
245
+ out.print(Syntax(preview, "toml", word_wrap=True))
246
+ if not args.write:
247
+ out.print("Dry run: nothing saved. Run gitpro setup --write to review and save.")
248
+ return 0
249
+ out.print("Only the config file will be saved. Existing config files are backed up first.")
250
+ if not Confirm.ask("Save this configuration?", default=False, console=out):
251
+ view.aborted()
252
+ return 1
253
+ backup = _save(path, text, before)
254
+ view.ok(f"saved {path}")
255
+ if backup:
256
+ view.step(f"backup: {backup}")
257
+ extra = " -c " + shlex.quote(str(path))
258
+ view.step(f"review Git/SSH changes: gitpro plan{extra}")
259
+ view.step(f"apply them when ready: gitpro apply{extra}")
260
+ return 0
gitpro/trailers.py ADDED
@@ -0,0 +1,218 @@
1
+ """Trailer policy per identity (`co_authored_by = allow | forbid | require`, `forbid_emails`) and the
2
+ guard's commit-msg entry `hook_message(ctx)`.
3
+
4
+ Hot path: the commit-msg hook imports this module, so it imports only the standard library and
5
+ gitpro.config at module level (resolve is imported inside hook_message, where guard has loaded it already).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+ import subprocess
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path
14
+ from typing import List, Optional, Sequence, Tuple
15
+
16
+ from .config import ConfigError
17
+
18
+ BLOCK = 3 # guard.BLOCK; not imported, guard is not needed to judge a message
19
+ GIT_TIMEOUT = 5 # guard.GIT_TIMEOUT: every git call inside a hook
20
+ CO_AUTHOR = "co-authored-by"
21
+ VALUES = ("allow", "forbid", "require")
22
+
23
+ Trailer = Tuple[str, str]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Policy:
28
+ """The trailer policy that applies to one identity, and where each part came from (for messages)."""
29
+ co_authored_by: str = "allow" # "allow" | "forbid" | "require"
30
+ forbid_emails: Tuple[str, ...] = ()
31
+ who: str = "the [default] identity" # "identity work-github" | "the [default] identity"
32
+ table: str = "" # "[identity.trailers]" | "[default.trailers]" | "" (built-in allow)
33
+ emails_table: str = "" # the same, for forbid_emails
34
+
35
+ def active(self) -> bool:
36
+ """False when nothing can be a violation (allow and no forbid_emails): no need to read trailers."""
37
+ return self.co_authored_by != "allow" or bool(self.forbid_emails)
38
+
39
+
40
+ def _trailers_of(obj: object) -> object:
41
+ """An Identity / Default's `trailers`, or obj itself when it already is a Trailers (or None)."""
42
+ return getattr(obj, "trailers", obj)
43
+
44
+
45
+ def policy_for(identity: object, default: object) -> Policy:
46
+ """identity's [identity.trailers] value, else [default.trailers], else allow / no forbidden emails.
47
+ identity: config.Identity or None (no identity matched); default: config.Default (or its Trailers)."""
48
+ it, dt = _trailers_of(identity), _trailers_of(default)
49
+ who = f"identity {identity.id}" if getattr(identity, "id", None) else "the [default] identity"
50
+ co, table = "allow", ""
51
+ for t, name in ((it, "[identity.trailers]"), (dt, "[default.trailers]")):
52
+ if t is not None and getattr(t, "co_authored_by", None):
53
+ co, table = t.co_authored_by, name
54
+ break
55
+ emails, etable = (), ""
56
+ for t, name in ((it, "[identity.trailers]"), (dt, "[default.trailers]")):
57
+ if t is not None and getattr(t, "forbid_emails", None) is not None:
58
+ emails, etable = tuple(t.forbid_emails), name
59
+ break
60
+ return Policy(co, emails, who, table, etable)
61
+
62
+
63
+ # ---------------------------------------------------------------- matching
64
+
65
+ def glob_match(pattern: str, text: str) -> bool:
66
+ """Case-insensitive glob with `*` and `?`; `[` is always a literal character, never a class:
67
+ "*[bot]@users.noreply.github.com" matches "dependabot[bot]@users.noreply.github.com" but not
68
+ "123+scott@users.noreply.github.com"."""
69
+ return fnmatch.fnmatchcase(text.casefold(), pattern.casefold().replace("[", "[[]"))
70
+
71
+
72
+ def email_of(value: str) -> str:
73
+ """The address in a trailer value "Name <email>", else the value itself."""
74
+ v = value.strip()
75
+ lt, gt = v.rfind("<"), v.rfind(">")
76
+ return v[lt + 1:gt].strip() if 0 <= lt < gt else v
77
+
78
+
79
+ def is_co_author(key: str) -> bool:
80
+ return key.strip().casefold() == CO_AUTHOR
81
+
82
+
83
+ def split_lines(text: str) -> List[Trailer]:
84
+ """(key, value) per "Key: value" line, as `git interpret-trailers --parse` and %(trailers:only,unfold)
85
+ print them."""
86
+ out: List[Trailer] = []
87
+ for line in text.splitlines():
88
+ key, sep, value = line.partition(":")
89
+ if sep and key.strip():
90
+ out.append((key.strip(), value.strip()))
91
+ return out
92
+
93
+
94
+ # ---------------------------------------------------------------- checking
95
+
96
+ def violations(trailers: Sequence[Trailer], policy: Policy) -> List[Tuple[str, str]]:
97
+ """(message, fix) per violation of policy by a commit message carrying `trailers`."""
98
+ out: List[Tuple[str, str]] = []
99
+ co = [(k, v) for k, v in trailers if is_co_author(k)]
100
+ for k, v in co:
101
+ shown = f"{k}: {v}"
102
+ if policy.co_authored_by == "forbid":
103
+ out.append((f"{shown} is not allowed for {policy.who} "
104
+ f"({policy.table} co_authored_by = \"forbid\")", "remove the trailer from the message"))
105
+ continue
106
+ hit = next((g for g in policy.forbid_emails if glob_match(g, email_of(v))), None)
107
+ if hit is not None:
108
+ out.append((f"{shown} is not allowed for {policy.who} ({policy.emails_table} forbid_emails has "
109
+ f"\"{hit}\")", "remove the trailer from the message"))
110
+ if policy.co_authored_by == "require" and not co:
111
+ out.append((f"{policy.who} requires a Co-Authored-By trailer on every commit",
112
+ "end the message with a trailer line: Co-Authored-By: Name <email>"))
113
+ return out
114
+
115
+
116
+ def check(trailers: Sequence[Trailer], policy: Policy) -> List[str]:
117
+ """The violation messages (empty when the trailers satisfy the policy)."""
118
+ return [m for m, _ in violations(trailers, policy)]
119
+
120
+
121
+ # ---------------------------------------------------------------- reading trailers
122
+
123
+ def _interpret(argv: List[str], stdin: Optional[str] = None) -> List[Trailer]:
124
+ try:
125
+ r = subprocess.run(["git", "interpret-trailers", "--parse", "--no-divider", *argv], input=stdin,
126
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
127
+ timeout=GIT_TIMEOUT, **({} if stdin is not None else {"stdin": subprocess.DEVNULL}))
128
+ except FileNotFoundError:
129
+ raise ConfigError("git not found") from None
130
+ except subprocess.TimeoutExpired:
131
+ raise ConfigError(f"git interpret-trailers timed out after {GIT_TIMEOUT}s") from None
132
+ if r.returncode:
133
+ raise ConfigError(f"git interpret-trailers failed: {r.stderr.strip() or r.returncode}")
134
+ return split_lines(r.stdout)
135
+
136
+
137
+ def parse(file: object) -> List[Trailer]:
138
+ """The trailers of the commit message in `file`, as git sees them (`git interpret-trailers --parse`:
139
+ comment lines and a scissors line are ignored, folded values unfolded)."""
140
+ return _interpret([str(file)])
141
+
142
+
143
+ def parse_text(message: str) -> List[Trailer]:
144
+ """parse() for a message held in a string."""
145
+ return _interpret([], message)
146
+
147
+
148
+ # ---------------------------------------------------------------- commit-msg entry (01 §5.6)
149
+
150
+ def _quiet_already(ctx) -> bool:
151
+ """guard's warn-once rule: pre-commit of this git command already printed a fail-open warning."""
152
+ f, pid = os.environ.get("GITPRO_WARNED_FILE"), os.environ.get("GITPRO_GIT_PID")
153
+ if not (f and pid and ctx.hook == "commit-msg"):
154
+ return False
155
+ try:
156
+ return Path(f).read_text(encoding="utf-8", errors="replace").strip() == pid
157
+ except OSError:
158
+ return False
159
+
160
+
161
+ def _in_merge() -> bool:
162
+ """A merge commit is being made (git merge runs commit-msg too); merges need no Co-Authored-By."""
163
+ try:
164
+ r = subprocess.run(["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"], capture_output=True, text=True,
165
+ stdin=subprocess.DEVNULL, timeout=GIT_TIMEOUT)
166
+ except (OSError, subprocess.SubprocessError):
167
+ return False
168
+ return r.returncode == 0
169
+
170
+
171
+ def _policy(ctx) -> Policy:
172
+ report = getattr(ctx, "report", None)
173
+ if report is not None:
174
+ exp = report.expected
175
+ else:
176
+ from . import resolve
177
+ exp = resolve.expected(Path(ctx.repo), ctx.cfg)
178
+ return policy_for(exp.match.identity, ctx.cfg.default)
179
+
180
+
181
+ def _hook_message(ctx) -> int:
182
+ if not ctx.args:
183
+ raise ConfigError("commit-msg called without a message file")
184
+ policy = _policy(ctx)
185
+ if not policy.active():
186
+ return 0
187
+ if policy.co_authored_by == "require" and _in_merge():
188
+ policy = replace(policy, co_authored_by="allow")
189
+ if not policy.active():
190
+ return 0
191
+ found = violations(parse(ctx.args[0]), policy)
192
+ if not found:
193
+ return 0
194
+ warn = getattr(getattr(ctx.cfg, "guard", None), "on_mismatch", "block") != "block"
195
+ for msg, _ in found:
196
+ ctx.say(("⚠" if warn else "✗") + f" gitpro: {msg}")
197
+ for fix in dict.fromkeys(f for _, f in found):
198
+ ctx.say(f" fix: {fix}")
199
+ if warn:
200
+ ctx.say(' (not blocking: [guard] on_mismatch = "warn")')
201
+ return BLOCK
202
+
203
+
204
+ def hook_message(ctx) -> int:
205
+ """guard.HOOKS["commit-msg"] entry: BLOCK (3) when the message's trailers break the trailer policy of
206
+ the repo's identity, else 0. Never raises: any failure is one ⚠ line and 0 (fail open)."""
207
+ try:
208
+ return _hook_message(ctx)
209
+ except KeyboardInterrupt:
210
+ raise
211
+ except BaseException as e: # noqa: BLE001 -- a broken gitpro must never block a commit
212
+ msg = str(e) if isinstance(e, ConfigError) else (f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
213
+ try:
214
+ if not _quiet_already(ctx):
215
+ ctx.say(f"⚠ gitpro trailers: {msg}; not blocking")
216
+ except Exception: # noqa: BLE001
217
+ pass
218
+ return 0
gitpro/tui.py ADDED
@@ -0,0 +1,178 @@
1
+ """Interactive scan --fix (optional: pip install 'gitpro[tui]'): tick the repos to fix, preview, confirm."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable, List, Optional
5
+
6
+ from rich.syntax import Syntax
7
+ from rich.text import Text
8
+ from textual.app import App, ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, DataTable, Footer, Header, Label, Static
13
+
14
+ from .view import plural, who
15
+
16
+ ON, OFF = "☑", "☐"
17
+
18
+
19
+ class Ask(ModalScreen[bool]):
20
+ BINDINGS = [Binding("y", "answer(True)", "Remove"), Binding("n,escape", "answer(False)", "Cancel")]
21
+
22
+ def __init__(self, question: str) -> None:
23
+ super().__init__()
24
+ self.question = question
25
+
26
+ def compose(self) -> ComposeResult:
27
+ with Vertical(classes="dialog"):
28
+ yield Label(self.question)
29
+ with Horizontal(classes="buttons"):
30
+ yield Button("Remove", variant="error", id="yes")
31
+ yield Button("Cancel", id="no")
32
+
33
+ def on_button_pressed(self, event: Button.Pressed) -> None:
34
+ self.dismiss(event.button.id == "yes")
35
+
36
+ def action_answer(self, yes: bool) -> None:
37
+ self.dismiss(yes)
38
+
39
+
40
+ class Suggestion(ModalScreen[None]):
41
+ BINDINGS = [Binding("c", "copy", "Copy"), Binding("escape,q,s", "app.pop_screen", "Close")]
42
+
43
+ def __init__(self, toml: str) -> None:
44
+ super().__init__()
45
+ self.toml = toml
46
+
47
+ def compose(self) -> ComposeResult:
48
+ with Vertical(classes="dialog"):
49
+ yield Static("To keep this identity, paste the block into your config and run 'gitpro apply'. "
50
+ "The repos it matches then keep it after the fix.\n")
51
+ yield Static(Syntax(self.toml, "toml", theme="ansi_dark", background_color="default"))
52
+ yield Footer()
53
+
54
+ def action_copy(self) -> None:
55
+ self.app.copy_to_clipboard(self.toml)
56
+ self.notify("copied")
57
+
58
+
59
+ class ScanApp(App[Optional[List[str]]]): # not list[str] | None: evaluated at runtime on 3.9
60
+ TITLE = "gitpro scan"
61
+ CSS = """
62
+ #target { padding: 0 1; color: $text-muted; }
63
+ DataTable { height: 1fr; }
64
+ #detail { height: auto; max-height: 12; border-top: solid $primary; padding: 0 1; }
65
+ Ask, Suggestion { align: center middle; }
66
+ .dialog Static { width: 100%; }
67
+ .dialog { width: 90%; max-width: 100; height: auto; border: thick $primary; background: $surface; padding: 1 2; }
68
+ .buttons { height: auto; margin-top: 1; }
69
+ .buttons Button { margin-right: 2; }
70
+ """
71
+ BINDINGS = [
72
+ Binding("space", "toggle", "Tick"),
73
+ Binding("a", "toggle_all", "All"),
74
+ Binding("s", "suggest", "Keep identity"),
75
+ Binding("enter", "fix", "Fix ticked", priority=True),
76
+ Binding("q,escape", "quit", "Quit"),
77
+ ]
78
+
79
+ def __init__(self, rep: dict, show: Callable[[str], str]) -> None:
80
+ super().__init__()
81
+ self.rep, self.show = rep, show
82
+ fixable = [r for r in rep["overrides"] if r["fixable"]]
83
+ # Same order as the plain output: by identity after the fix, then by the identity now, unchanged last.
84
+ self.rows = sorted(fixable, key=lambda r: (who(r["after"]), who(r["now"]) == who(r["after"]),
85
+ who(r["now"]), r["short"]))
86
+ self.ticked = {r["path"] for r in self.rows}
87
+
88
+ def compose(self) -> ComposeResult:
89
+ yield Header()
90
+ yield Static(id="target")
91
+ yield DataTable(cursor_type="row", zebra_stripes=True)
92
+ yield Static(id="detail")
93
+ yield Footer()
94
+
95
+ def on_mount(self) -> None:
96
+ table = self.query_one(DataTable)
97
+ table.add_column(" ", key="tick")
98
+ targets = {who(r["after"]) for r in self.rows}
99
+ one = len(targets) == 1
100
+ table.add_columns("repo", "now (yellow: changes)" if one else "now", *([] if one else ["after the fix"]))
101
+ target = self.query_one("#target", Static)
102
+ if one:
103
+ target.update(Text("After the fix they all use ") + Text(targets.pop(), style="bold green"))
104
+ else:
105
+ target.display = False
106
+ for r in self.rows:
107
+ now, after = who(r["now"]), who(r["after"])
108
+ cells = [Text(now, style="yellow" if now != after else "dim")]
109
+ if not one:
110
+ cells.append(Text("unchanged", style="dim") if now == after else Text(after, style="green"))
111
+ table.add_row(ON, r["short"], *cells, key=r["path"])
112
+ self.sub_title = self.counts()
113
+ table.focus()
114
+
115
+ def counts(self) -> str:
116
+ n = sum(len(r["fixable"]) for r in self.rows if r["path"] in self.ticked)
117
+ return f"{plural(len(self.ticked), 'repo')} ticked, {plural(n, 'setting')} to remove"
118
+
119
+ def current(self) -> dict | None:
120
+ table = self.query_one(DataTable)
121
+ return self.rows[table.cursor_row] if self.rows else None
122
+
123
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
124
+ r = self.current()
125
+ if not r:
126
+ return
127
+ text = Text(self.show(r["path"]), style="bold")
128
+ text.append(f" {r['owner']}\n", style="dim")
129
+ for s in r["settings"]:
130
+ text.append(f" {s['key']:<11} {s['value']}")
131
+ text.append(" → " + s["without"] if s["without"] else " (kept: nothing else sets it)",
132
+ style="green" if s["without"] and s["without"] != s["value"] else "dim")
133
+ text.append("\n")
134
+ if self.suggestion(r):
135
+ text.append("s: keep this identity with a gitpro identity instead", style="dim")
136
+ self.query_one("#detail", Static).update(text)
137
+
138
+ def suggestion(self, r: dict) -> dict | None:
139
+ return next((s for s in self.rep["suggestions"] if s["email"] == r["now"]["email"]
140
+ and r["owner"] in {f"{s['host']}/{o}" for o in s["owners"]}), None)
141
+
142
+ def action_toggle(self) -> None:
143
+ r = self.current()
144
+ if r:
145
+ self.ticked ^= {r["path"]}
146
+ self.query_one(DataTable).update_cell(r["path"], "tick", ON if r["path"] in self.ticked else OFF)
147
+ self.sub_title = self.counts()
148
+
149
+ def action_toggle_all(self) -> None:
150
+ self.ticked = set() if self.ticked else {r["path"] for r in self.rows}
151
+ table = self.query_one(DataTable)
152
+ for r in self.rows:
153
+ table.update_cell(r["path"], "tick", ON if r["path"] in self.ticked else OFF)
154
+ self.sub_title = self.counts()
155
+
156
+ def action_suggest(self) -> None:
157
+ r = self.current()
158
+ s = r and self.suggestion(r)
159
+ if s:
160
+ self.push_screen(Suggestion(s["toml"]))
161
+ else:
162
+ self.notify("no identity can keep this one: it has no remote gitpro can match", severity="warning")
163
+
164
+ def action_fix(self) -> None:
165
+ if not self.ticked:
166
+ self.notify("nothing ticked", severity="warning")
167
+ return
168
+ n = sum(len(r["fixable"]) for r in self.rows if r["path"] in self.ticked)
169
+ self.push_screen(Ask(f"Remove {plural(n, 'setting')} from {plural(len(self.ticked), 'repo')}?"),
170
+ lambda yes: self.exit(sorted(self.ticked)) if yes else None)
171
+
172
+ def action_quit(self) -> None:
173
+ self.exit(None)
174
+
175
+
176
+ def choose(rep: dict, show: Callable[[str], str]) -> list[str] | None:
177
+ """Paths of the repos to fix, or None if the user quit."""
178
+ return ScanApp(rep, show).run()