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/envcmd.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""`gitpro env`: shell lines that point gh / glab / ssh / git at the identity of the current repo.
|
|
2
|
+
|
|
3
|
+
The lines FETCH the token (`gh auth token -u ...`); gitpro never runs that command (except for the
|
|
4
|
+
explicit --check probe, which discards the output) and never sees or prints a token.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from . import config, forge, jsonout, resolve
|
|
19
|
+
from .args import capability, fail, guarded
|
|
20
|
+
from .config import Config, ConfigError
|
|
21
|
+
|
|
22
|
+
SHELLS = ("sh", "fish", "pwsh")
|
|
23
|
+
KEYS = ("gh", "glab", "ssh", "author")
|
|
24
|
+
DEFAULT_ONLY = ("gh", "glab")
|
|
25
|
+
SAFE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
26
|
+
PROBE_TIMEOUT = 10
|
|
27
|
+
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
|
|
28
|
+
SSH_NOTE = "# overrides core.sshCommand for every repo in this shell; unset when you cd elsewhere"
|
|
29
|
+
AUTHOR_NOTE = "# repo .git/config overrides the identity; these env vars win over it"
|
|
30
|
+
GLAB_OLD = "# glab auth token not available in your glab; set GITLAB_TOKEN yourself"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Item:
|
|
35
|
+
"""One variable: a known value, or a command whose output is the value."""
|
|
36
|
+
var: str
|
|
37
|
+
value: Optional[str] = None
|
|
38
|
+
cmd: Optional[List[Tuple[str, bool]]] = None # (word, quote-it) pairs
|
|
39
|
+
notes: List[str] = field(default_factory=list)
|
|
40
|
+
strip: Optional[str] = None # variable to clear for the command's own run
|
|
41
|
+
|
|
42
|
+
def plain(self) -> Optional[str]:
|
|
43
|
+
return " ".join(w for w, _ in self.cmd) if self.cmd else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Plan:
|
|
48
|
+
who: str # identity id, or "default" for the [default] fallback
|
|
49
|
+
fallback: bool # no identity matched; [default] accounts apply
|
|
50
|
+
items: List[Item]
|
|
51
|
+
notes: List[str] # comments that belong to no variable (after the header)
|
|
52
|
+
problems: List[str]
|
|
53
|
+
forge: Optional[dict] = None
|
|
54
|
+
ssh_key: Optional[str] = None
|
|
55
|
+
ok: bool = True # False: no identity, nothing to set (exit 1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------- quoting
|
|
59
|
+
|
|
60
|
+
def _sh(s: str) -> str:
|
|
61
|
+
return "'" + s.replace("'", "'\\''") + "'"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fish(s: str) -> str:
|
|
65
|
+
return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pwsh(s: str) -> str:
|
|
69
|
+
"""PowerShell also treats U+2018..U+201B as single quotes; every one is doubled."""
|
|
70
|
+
return "'" + "".join(c * 2 if c in "'\u2018\u2019\u201a\u201b" else c for c in s) + "'"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_QUOTE = {"sh": _sh, "fish": _fish, "pwsh": _pwsh}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _command(cmd: Sequence[Tuple[str, bool]], q) -> str:
|
|
77
|
+
return " ".join(q(w) if quoted else w for w, quoted in cmd)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _set_line(shell: str, it: Item) -> str:
|
|
81
|
+
q = _QUOTE[shell]
|
|
82
|
+
if it.cmd:
|
|
83
|
+
c = _command(it.cmd, q)
|
|
84
|
+
if it.strip:
|
|
85
|
+
c = {"sh": f"unset {it.strip}; {c}", "fish": f"env -u {it.strip} {c}",
|
|
86
|
+
"pwsh": f"& {{ Remove-Item Env:{it.strip} -ErrorAction SilentlyContinue; {c} }}"}[shell]
|
|
87
|
+
return {"sh": f'export {it.var}="$({c})"', "fish": f"set -gx {it.var} ({c})",
|
|
88
|
+
"pwsh": f"$env:{it.var} = ({c})"}[shell]
|
|
89
|
+
v = q(it.value or "")
|
|
90
|
+
return {"sh": f"export {it.var}={v}", "fish": f"set -gx {it.var} {v}", "pwsh": f"$env:{it.var} = {v}"}[shell]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
UNSET_VARS = {"gh": ("GH_HOST", "GH_TOKEN", "GH_ENTERPRISE_TOKEN"), "glab": ("GITLAB_HOST", "GITLAB_TOKEN"),
|
|
94
|
+
"ssh": ("GIT_SSH_COMMAND",),
|
|
95
|
+
"author": ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL")}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unset_lines(shell: str, keys: Sequence[str]) -> List[str]:
|
|
99
|
+
"""Clear every variable the requested keys can set, whatever the repo matched."""
|
|
100
|
+
names = [n for k in KEYS if k in keys for n in UNSET_VARS[k]]
|
|
101
|
+
if not names:
|
|
102
|
+
return []
|
|
103
|
+
if shell == "sh":
|
|
104
|
+
return ["unset " + " ".join(names)]
|
|
105
|
+
if shell == "fish":
|
|
106
|
+
return [f"set -e -g {n}" for n in names]
|
|
107
|
+
return ["Remove-Item " + ", ".join(f"Env:{n}" for n in names) + " -ErrorAction SilentlyContinue"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _comment(text: str) -> str:
|
|
111
|
+
"""One comment line: control characters dropped, so no text can end the comment early."""
|
|
112
|
+
return "# " + _CTRL.sub(" ", text)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------- planning
|
|
116
|
+
|
|
117
|
+
def _valid(value: str, what: str) -> str:
|
|
118
|
+
if not SAFE.match(value):
|
|
119
|
+
raise ConfigError(f"invalid {what} {value!r}: use only letters, digits, '.', '_' and '-'")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _glab_has_token_cmd() -> bool:
|
|
124
|
+
"""`glab auth token --help` exits 0; False when glab is missing or too old."""
|
|
125
|
+
if not shutil.which("glab"):
|
|
126
|
+
return False
|
|
127
|
+
try:
|
|
128
|
+
r = subprocess.run(["glab", "auth", "token", "--help"], stdin=subprocess.DEVNULL, capture_output=True,
|
|
129
|
+
timeout=PROBE_TIMEOUT)
|
|
130
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
131
|
+
return False
|
|
132
|
+
return r.returncode == 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ambient(var: str) -> List[str]:
|
|
136
|
+
"""Note the presence of a token variable, never its value."""
|
|
137
|
+
if os.environ.get(var):
|
|
138
|
+
return [f"# {var} is already set in this shell; the line below replaces it"]
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _accounts(rep: resolve.Report, cfg: Config) -> Tuple[str, bool, str, Optional[str], Optional[str]]:
|
|
143
|
+
"""(who, fallback, host, gh_account, glab_account). A matched identity uses only its own accounts.
|
|
144
|
+
With no match, [default]'s gh account applies to a github.com remote and its glab account to gitlab.com."""
|
|
145
|
+
i, d = rep.expected.match.identity, cfg.default
|
|
146
|
+
if i is not None:
|
|
147
|
+
return i.id, False, i.host, i.gh_account, i.glab_account
|
|
148
|
+
rems = rep.effective.remotes
|
|
149
|
+
host = resolve.parse_url(next(iter(rems.values())))[0] if rems else None
|
|
150
|
+
host = (host or "").lower()
|
|
151
|
+
return ("default", True, host, d.gh_account if host == forge.GITHUB else None,
|
|
152
|
+
d.glab_account if host == forge.GITLAB else None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ssh_command(key: str) -> str:
|
|
156
|
+
"""ssh -i KEY -o IdentitiesOnly=yes; ~/ is kept when the rest is plain (git runs the line through a shell)."""
|
|
157
|
+
home = str(Path.home())
|
|
158
|
+
rest = key[len(home) + 1:] if key.startswith(home + "/") else ""
|
|
159
|
+
shown = "~/" + rest if rest and re.match(r"^[A-Za-z0-9._/-]+$", rest) else shlex.quote(key)
|
|
160
|
+
return f"ssh -i {shown} -o IdentitiesOnly=yes"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _gh_items(host: str, account: str) -> Tuple[List[Item], dict]:
|
|
164
|
+
host, account = _valid(host, "host"), _valid(account, "gh_account")
|
|
165
|
+
var = "GH_TOKEN" if host == forge.GITHUB else "GH_ENTERPRISE_TOKEN"
|
|
166
|
+
cmd = [("gh", False), ("auth", False), ("token", False), ("-u", False), (account, True),
|
|
167
|
+
("-h", False), (host, True)]
|
|
168
|
+
items = [Item("GH_HOST", host), Item(var, cmd=cmd, notes=_ambient(var))]
|
|
169
|
+
return items, {"host": host, "cli": "gh", "account": account, "token_var": var}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _glab_items(host: str, account: str) -> Tuple[List[Item], List[str], dict]:
|
|
173
|
+
host, account = _valid(host, "host"), _valid(account, "glab_account")
|
|
174
|
+
info = {"host": host, "cli": "glab", "account": account, "token_var": "GITLAB_TOKEN"}
|
|
175
|
+
if not shutil.which("glab"):
|
|
176
|
+
return [], [], info
|
|
177
|
+
if not _glab_has_token_cmd():
|
|
178
|
+
return [], [GLAB_OLD], info
|
|
179
|
+
cmd = [("glab", False), ("auth", False), ("token", False), ("-h", False), (host, True)]
|
|
180
|
+
return [Item("GITLAB_HOST", host), Item("GITLAB_TOKEN", cmd=cmd, notes=_ambient("GITLAB_TOKEN"), strip="GITLAB_TOKEN")], [], info
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def keys_for(rep: resolve.Report, only: Optional[Sequence[str]]) -> Tuple[str, ...]:
|
|
184
|
+
"""The keys to handle: --only, else gh,glab (plus author when the repo overrides the identity)."""
|
|
185
|
+
if only:
|
|
186
|
+
return tuple(only)
|
|
187
|
+
over = any(p.code in ("override", "env_override") for p in rep.problems)
|
|
188
|
+
return DEFAULT_ONLY + (("author",) if over else ())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build(rep: resolve.Report, cfg: Config, only: Sequence[str], explicit: bool) -> Plan:
|
|
192
|
+
"""What to set for the repo in rep. only: the keys asked for; explicit: they came from --only."""
|
|
193
|
+
who, fallback, host, gh, glab = _accounts(rep, cfg)
|
|
194
|
+
exp = rep.expected
|
|
195
|
+
problems = [p for p in rep.problems if p.code != "no_identity"]
|
|
196
|
+
items: List[Item] = []
|
|
197
|
+
notes: List[str] = []
|
|
198
|
+
forge_info: Optional[dict] = None
|
|
199
|
+
if "gh" in only and gh:
|
|
200
|
+
got, forge_info = _gh_items(host, gh)
|
|
201
|
+
items += got
|
|
202
|
+
if "glab" in only and glab:
|
|
203
|
+
got, extra, info = _glab_items(host, glab)
|
|
204
|
+
items += got
|
|
205
|
+
notes += extra
|
|
206
|
+
forge_info = forge_info or info
|
|
207
|
+
if "ssh" in only:
|
|
208
|
+
if exp.ssh_key:
|
|
209
|
+
items.append(Item("GIT_SSH_COMMAND", _ssh_command(exp.ssh_key), notes=[SSH_NOTE]))
|
|
210
|
+
else:
|
|
211
|
+
notes.append(f"# identity {who} has no ssh_key; nothing to set for ssh")
|
|
212
|
+
overridden = any(p.code in ("override", "env_override") for p in problems)
|
|
213
|
+
if "author" in only:
|
|
214
|
+
if exp.name and exp.email:
|
|
215
|
+
first = True
|
|
216
|
+
for var, val in (("GIT_AUTHOR_NAME", exp.name), ("GIT_AUTHOR_EMAIL", exp.email),
|
|
217
|
+
("GIT_COMMITTER_NAME", exp.name), ("GIT_COMMITTER_EMAIL", exp.email)):
|
|
218
|
+
items.append(Item(var, val, notes=[AUTHOR_NOTE] if first and overridden else []))
|
|
219
|
+
first = False
|
|
220
|
+
else:
|
|
221
|
+
notes.append(f"# identity {who} has no name/email; no author variables to set")
|
|
222
|
+
if not items and not notes:
|
|
223
|
+
if "gh" in only and "glab" in only and not gh and not glab and not fallback:
|
|
224
|
+
notes.append(f"# gitpro env: identity {who} has no gh_account/glab_account; nothing to set")
|
|
225
|
+
else:
|
|
226
|
+
notes.append(f"# gitpro env: nothing to set for identity {who}")
|
|
227
|
+
return Plan(who, fallback, items, notes, [p.message for p in problems], forge_info, exp.ssh_key)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _describe(rep: resolve.Report, plan: Plan) -> str:
|
|
231
|
+
e = rep.expected
|
|
232
|
+
m = e.match
|
|
233
|
+
if plan.fallback:
|
|
234
|
+
return "no identity matched; using [default]"
|
|
235
|
+
url = resolve.redact_url(m.url)
|
|
236
|
+
return f"identity {plan.who}" + (f" <{e.email}>" if e.email else "") + f" ({m.remote or 'url'} {url})"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _tail(shell: str, only: Sequence[str], explicit: bool) -> List[str]:
|
|
240
|
+
flag = f" --only {','.join(only)}" if explicit else ""
|
|
241
|
+
if shell == "sh":
|
|
242
|
+
return [f'# usage: eval "$(gitpro env{flag})" undo: eval "$(gitpro env{flag} --unset)"',
|
|
243
|
+
"# direnv: echo 'eval \"$(gitpro env)\"' >> .envrc && direnv allow"]
|
|
244
|
+
pipe = {"fish": "| source", "pwsh": "| Out-String | Invoke-Expression"}[shell]
|
|
245
|
+
return [f"# usage: gitpro env --shell {shell}{flag} {pipe} undo: gitpro env --shell {shell}{flag} "
|
|
246
|
+
f"--unset {pipe}"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def lines(rep: resolve.Report, cfg: Config, shell: str = "sh", only: Optional[Sequence[str]] = None,
|
|
250
|
+
unset: bool = False, plan: Optional[Plan] = None) -> List[str]:
|
|
251
|
+
"""The complete output, comments included, one string per line."""
|
|
252
|
+
shell = "sh" if shell in ("bash", "zsh") else shell
|
|
253
|
+
keys = keys_for(rep, only)
|
|
254
|
+
plan = plan or build(rep, cfg, keys, True)
|
|
255
|
+
out = [_comment(f"gitpro env for {_tilde(rep.repo)}: {_describe(rep, plan)}")]
|
|
256
|
+
if unset:
|
|
257
|
+
out += _unset_lines(shell, keys)
|
|
258
|
+
else:
|
|
259
|
+
out += plan.notes
|
|
260
|
+
for it in plan.items:
|
|
261
|
+
out += it.notes + [_set_line(shell, it)]
|
|
262
|
+
out += [_comment("⚠ " + m) for m in plan.problems]
|
|
263
|
+
return out + _tail(shell, keys, bool(only))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _tilde(path: Path) -> str:
|
|
267
|
+
home = str(Path.home())
|
|
268
|
+
s = str(path)
|
|
269
|
+
return "~" + s[len(home):] if s == home or s.startswith(home + "/") else s
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def as_json(rep: resolve.Report, cfg: Config, shell: str = "sh", only: Optional[Sequence[str]] = None,
|
|
273
|
+
unset: bool = False, plan: Optional[Plan] = None) -> dict:
|
|
274
|
+
"""The --json payload: values gitpro knows in `env`, commands for the rest in `env_commands`."""
|
|
275
|
+
keys = keys_for(rep, only)
|
|
276
|
+
plan = plan or build(rep, cfg, keys, True)
|
|
277
|
+
e = rep.expected
|
|
278
|
+
ident = {"id": e.id, "name": e.name, "email": e.email} if e.id else None
|
|
279
|
+
body = [ln for ln in lines(rep, cfg, shell, only, unset, plan) if not ln.startswith("#")]
|
|
280
|
+
return {"repo": str(rep.repo), "identity": ident, "forge": plan.forge, "ssh_key": plan.ssh_key,
|
|
281
|
+
"env": {i.var: i.value for i in plan.items if i.cmd is None},
|
|
282
|
+
"env_commands": {i.var: i.plain() for i in plan.items if i.cmd},
|
|
283
|
+
"lines": body, "notes": plan.notes, "problems": plan.problems}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------- the command
|
|
287
|
+
|
|
288
|
+
def _parse_only(raw: Optional[str], args) -> Optional[List[str]]:
|
|
289
|
+
if raw is None:
|
|
290
|
+
return None
|
|
291
|
+
keys = [k.strip() for k in raw.split(",") if k.strip()]
|
|
292
|
+
for k in keys:
|
|
293
|
+
if k not in KEYS:
|
|
294
|
+
raise ConfigError(f"unknown key '{k}' ({', '.join(KEYS)})")
|
|
295
|
+
return keys
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _check_gh(host: str, account: str) -> None:
|
|
299
|
+
"""--check: run the printed `gh auth token` once, discard the output (never shown)."""
|
|
300
|
+
try:
|
|
301
|
+
r = subprocess.run(["gh", "auth", "token", "-u", account, "-h", host], stdin=subprocess.DEVNULL,
|
|
302
|
+
capture_output=True, text=True, timeout=forge.TOKEN_TIMEOUT,
|
|
303
|
+
env=forge._stripped(forge._GH_TOKEN_VARS))
|
|
304
|
+
except FileNotFoundError:
|
|
305
|
+
raise ConfigError("gh not found; install the GitHub CLI (https://cli.github.com)") from None
|
|
306
|
+
except subprocess.TimeoutExpired:
|
|
307
|
+
raise ConfigError("gh auth token timed out (keyring locked?)") from None
|
|
308
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
309
|
+
if "unknown shorthand flag" in r.stderr or "unknown flag" in r.stderr:
|
|
310
|
+
raise ConfigError("gh 2.40 or newer is needed for multiple accounts")
|
|
311
|
+
raise ConfigError(f"gh has no login for {account} on {host}; run: gh auth login -h {host}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _no_output(args, text: str, rc: int) -> int:
|
|
315
|
+
"""A comment-only answer (safe to eval) or, under --json, the error object."""
|
|
316
|
+
if getattr(args, "json", False):
|
|
317
|
+
jsonout.error("env", text[2:] if text.startswith("# ") else text)
|
|
318
|
+
else:
|
|
319
|
+
print(text)
|
|
320
|
+
return rc
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_env(args) -> int:
|
|
324
|
+
only = _parse_only(args.only, args)
|
|
325
|
+
shell = {"bash": "sh", "zsh": "sh"}.get(args.shell, args.shell)
|
|
326
|
+
if shell not in SHELLS:
|
|
327
|
+
return fail(f"unknown shell '{args.shell}' ({', '.join(SHELLS)})", args)
|
|
328
|
+
resolve.require_git()
|
|
329
|
+
home = Path(args.home).expanduser().resolve()
|
|
330
|
+
try:
|
|
331
|
+
path, how = config.find(args.config, home)
|
|
332
|
+
except ConfigError as e:
|
|
333
|
+
return _no_output(args, f"# gitpro env: {e}", 2)
|
|
334
|
+
cfg = config.load_all(path, how)
|
|
335
|
+
top = resolve.is_repo(Path(args.dir))
|
|
336
|
+
if top is None:
|
|
337
|
+
return _no_output(args, f"# gitpro env: {_CTRL.sub(' ', str(args.dir))} is not a git repository", 2)
|
|
338
|
+
rep = resolve.compare(top, cfg)
|
|
339
|
+
keys = keys_for(rep, only)
|
|
340
|
+
plan = build(rep, cfg, keys, True)
|
|
341
|
+
who, fallback, host, gh, glab = _accounts(rep, cfg)
|
|
342
|
+
if fallback and not (gh or glab) and not args.unset:
|
|
343
|
+
rems = rep.effective.remotes
|
|
344
|
+
h, owner, _ = resolve.parse_url(next(iter(rems.values()))) if rems else (None, None, None)
|
|
345
|
+
shown = "/".join(x for x in (h, owner) if x) or str(top)
|
|
346
|
+
return _no_output(args, _comment(f"gitpro env: no identity for {shown}; nothing to set"), 1)
|
|
347
|
+
if args.check:
|
|
348
|
+
if gh and "gh" in keys:
|
|
349
|
+
_check_gh(_valid(host, "host"), _valid(gh, "gh_account"))
|
|
350
|
+
print(f"✓ gh token for {gh} on {host} is available", file=sys.stderr)
|
|
351
|
+
else:
|
|
352
|
+
print("· nothing to check: no gh account for this repo", file=sys.stderr)
|
|
353
|
+
if getattr(args, "json", False):
|
|
354
|
+
jsonout.emit("env", as_json(rep, cfg, shell, only, args.unset, plan))
|
|
355
|
+
else:
|
|
356
|
+
print("\n".join(lines(rep, cfg, shell, only, args.unset, plan)))
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def register(sub, common, confirm) -> None:
|
|
361
|
+
sp = sub.add_parser("env", parents=[common],
|
|
362
|
+
help="print shell lines that set GH_TOKEN etc. for the repo's identity (fetches, never prints, tokens)")
|
|
363
|
+
sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
|
|
364
|
+
sp.add_argument("--shell", default="sh", metavar="SHELL", help="sh (also bash, zsh), fish or pwsh (default: sh)")
|
|
365
|
+
sp.add_argument("--only", metavar="KEY[,KEY...]", help="gh, glab, ssh, author (default: gh,glab)")
|
|
366
|
+
sp.add_argument("--unset", action="store_true", help="print the lines that undo the same variables")
|
|
367
|
+
sp.add_argument("--check", action="store_true", help="run the gh auth token command once to see it works")
|
|
368
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
369
|
+
sp.set_defaults(func=lambda a: guarded(cmd_env, a))
|
|
370
|
+
capability("env")
|
gitpro/example.toml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# gitpro config. Docs: https://github.com/Yantrakar/gitpro
|
|
2
|
+
#
|
|
3
|
+
# [default] applies to every repo. Each [[identity]] overrides it for repos with
|
|
4
|
+
# a remote URL on `host` under one of `owners` (GitHub users/orgs, GitLab groups
|
|
5
|
+
# or subgroups). When several identities match, the later one wins.
|
|
6
|
+
#
|
|
7
|
+
# 1. Replace every placeholder below. Emails and hosts on example domains
|
|
8
|
+
# (*.example, example.com) are rejected, so nothing is left over by accident.
|
|
9
|
+
# 2. Delete the identities you don't need.
|
|
10
|
+
# 3. Run: gitpro plan
|
|
11
|
+
#
|
|
12
|
+
# This file decides which commands git runs (sshCommand, credential helpers), so
|
|
13
|
+
# it must be owned by you and not group/world-writable (chmod 600).
|
|
14
|
+
|
|
15
|
+
[default]
|
|
16
|
+
# Used in every repo that no identity matches. Leave name and email out to make
|
|
17
|
+
# git refuse to commit there instead: the safest choice when work and personal
|
|
18
|
+
# commits must never mix.
|
|
19
|
+
name = "Jane Doe"
|
|
20
|
+
email = "jane@personal.example" # GitHub no-reply form: 12345+janedoe@users.noreply.github.com
|
|
21
|
+
ssh_key = "~/.ssh/id_ed25519" # optional
|
|
22
|
+
ssh_hosts = ["github.com"] # optional: always use ssh_key for these hosts
|
|
23
|
+
sign = false # true: SSH-sign commits and tags (register the .pub as a *signing* key too)
|
|
24
|
+
# gh_account = "janedoe" # optional: the gh login that matches this identity (gitpro clone, forge)
|
|
25
|
+
# glab_account = "janedoe" # optional: the same for glab
|
|
26
|
+
# clone_dir = "~/src" # optional: where gitpro clone puts repos of this identity
|
|
27
|
+
|
|
28
|
+
# Optional: what commit trailers may say (identities inherit this unless they set their own).
|
|
29
|
+
# [default.trailers]
|
|
30
|
+
# co_authored_by = "allow" # "allow", "forbid" or "require"
|
|
31
|
+
# forbid_emails = ["*@corp.example"] # Co-authored-by emails that are never allowed (globs)
|
|
32
|
+
|
|
33
|
+
# Optional extra git config: section -> [subsection ->] key = value or [values].
|
|
34
|
+
# For example, the GitHub CLI as HTTPS credential helper:
|
|
35
|
+
# [default.git.credential."https://github.com"]
|
|
36
|
+
# helper = ["", "!gh auth git-credential"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# A work GitLab: its own name and email, the default SSH key.
|
|
40
|
+
[[identity]]
|
|
41
|
+
id = "work-gitlab" # generated file: ~/.gitconfig-work-gitlab
|
|
42
|
+
name = "Jane Doe"
|
|
43
|
+
email = "jane.doe@corp.example"
|
|
44
|
+
host = "gitlab.corp.example"
|
|
45
|
+
owners = ["corp"] # exact case as in the remote URL; omit to match the whole host
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# A second GitHub account with its own SSH key.
|
|
49
|
+
[[identity]]
|
|
50
|
+
id = "work-github"
|
|
51
|
+
name = "Jane Doe"
|
|
52
|
+
email = "jane.doe@corp.example"
|
|
53
|
+
host = "github.com"
|
|
54
|
+
owners = ["janedoe-corp", "corp-org"] # the account itself and each org you reach through it
|
|
55
|
+
ssh_key = "~/.ssh/id_ed25519_work"
|
|
56
|
+
ssh_alias = "github-work" # ssh Host that offers only ssh_key; test: ssh -T git@github-work
|
|
57
|
+
# Optional, with their defaults:
|
|
58
|
+
# rewrite = true # send git@github.com:<owner>/... through ssh_alias
|
|
59
|
+
# rewrite_https = false # also send https://github.com/<owner>/... over SSH through ssh_alias
|
|
60
|
+
# ssh_user = "git"
|
|
61
|
+
# ssh_port = 22 # change only for a non-standard SSH port
|
|
62
|
+
# sign = false # default: same as [default].sign
|
|
63
|
+
# file = "~/.gitconfig-work-github" # must be inside your home directory
|
|
64
|
+
# Optional, no default:
|
|
65
|
+
# gh_account = "janedoe-corp" # the gh login for this identity
|
|
66
|
+
# glab_account = "jdoe" # the glab login for this identity
|
|
67
|
+
# clone_dir = "~/work" # where gitpro clone puts this identity's repos
|
|
68
|
+
# policy = "~/work/policy.toml" # team policy file for repos of this identity
|
|
69
|
+
# [identity.trailers] # same keys as [default.trailers]
|
|
70
|
+
# co_authored_by = "forbid"
|
|
71
|
+
|
|
72
|
+
# Optional extra git config for the identity above it:
|
|
73
|
+
# [identity.git.pull]
|
|
74
|
+
# ff = "only"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Optional tables, shown with their defaults.
|
|
78
|
+
|
|
79
|
+
# [scan] # gitpro scan / lost
|
|
80
|
+
# roots = ["~"] # where to look for repos (~/... or absolute)
|
|
81
|
+
# exclude = [] # globs to skip, e.g. ["~/.cache/**"]
|
|
82
|
+
# stale_days = 180 # repos untouched this long count as stale
|
|
83
|
+
|
|
84
|
+
# [guard] # git hooks that check the identity before each commit/push
|
|
85
|
+
# hooks = false
|
|
86
|
+
# on_mismatch = "block" # "block" or "warn"
|
|
87
|
+
# chain_to = "" # your own hooks dir to run afterwards (~/... or absolute)
|
|
88
|
+
|
|
89
|
+
# [clone] # gitpro clone
|
|
90
|
+
# dir = "~/src" # default: none (the current directory)
|
|
91
|
+
# layout = "{host}/{owner}/{repo}" # may use {host} {owner} {repo} {id}
|
|
92
|
+
|
|
93
|
+
# [forge] # online checks through gh / glab
|
|
94
|
+
# online = false
|
|
95
|
+
# similar = false
|
|
96
|
+
|
|
97
|
+
# [agents] # AI coding agents
|
|
98
|
+
# hook = "block" # "block", "warn" or "off"
|
|
99
|
+
# bot_emails = ["*[bot]@users.noreply.github.com", "noreply@anthropic.com"]
|
|
100
|
+
|
|
101
|
+
# [policy]
|
|
102
|
+
# files = [] # team policy files (~/... or absolute)
|
gitpro/files.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Compare targets with the disk, show the difference, apply it. The only module that writes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # plan and render load lazily, so modules that only need atomic_write stay cheap
|
|
15
|
+
from .plan import Target
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_generated(text: str) -> bool:
|
|
19
|
+
from .render import is_generated as _is_generated
|
|
20
|
+
return _is_generated(text)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_block(text: str) -> bool:
|
|
24
|
+
from .render import has_block as _has_block
|
|
25
|
+
return _has_block(text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def without_block(text: str) -> str:
|
|
29
|
+
from .render import without_block as _without_block
|
|
30
|
+
return _without_block(text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Change:
|
|
35
|
+
action: str # "create" | "update" | "chmod" | "remove"
|
|
36
|
+
path: Path
|
|
37
|
+
old: str | None
|
|
38
|
+
new: str | None
|
|
39
|
+
mode: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def diff(targets: list[Target], stale: list[Path]) -> list[Change]:
|
|
43
|
+
changes = []
|
|
44
|
+
for t in targets:
|
|
45
|
+
path = t.path.resolve() if t.path.is_symlink() else t.path
|
|
46
|
+
old = path.read_text() if path.exists() else None
|
|
47
|
+
cur = path.stat().st_mode & 0o777 if old is not None else None
|
|
48
|
+
mode = t.mode if t.strict or cur is None else cur & 0o755
|
|
49
|
+
if t.text is None:
|
|
50
|
+
if old is not None:
|
|
51
|
+
changes.append(Change("remove", path, old, None, 0))
|
|
52
|
+
elif old != t.text:
|
|
53
|
+
changes.append(Change("create" if old is None else "update", path, old, t.text, mode))
|
|
54
|
+
elif cur != mode:
|
|
55
|
+
changes.append(Change("chmod", path, old, old, mode))
|
|
56
|
+
for path in dict.fromkeys(stale):
|
|
57
|
+
if path.is_file() and not path.is_symlink():
|
|
58
|
+
old = path.read_text()
|
|
59
|
+
if is_generated(old):
|
|
60
|
+
changes.append(Change("remove", path, old, None, 0))
|
|
61
|
+
return changes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def show(changes: list[Change], diffs: bool) -> None:
|
|
65
|
+
if not changes:
|
|
66
|
+
print("up to date: nothing to change")
|
|
67
|
+
return
|
|
68
|
+
for c in changes:
|
|
69
|
+
print(f" {c.action:7} {c.path}" + (f" -> {c.mode:o}" if c.action == "chmod" else ""))
|
|
70
|
+
if diffs and c.action != "chmod":
|
|
71
|
+
sys.stdout.writelines(difflib.unified_diff(
|
|
72
|
+
(c.old or "").splitlines(True), (c.new or "").splitlines(True), f"a{c.path}", f"b{c.path}"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _backup(path: Path, data: bytes | None = None) -> Path:
|
|
76
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
77
|
+
if data is None:
|
|
78
|
+
data = path.read_bytes()
|
|
79
|
+
for n in range(100):
|
|
80
|
+
bak = path.with_name(f"{path.name}.bak.{stamp}" + (f".{n}" if n else ""))
|
|
81
|
+
try:
|
|
82
|
+
fd = os.open(bak, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
83
|
+
except FileExistsError:
|
|
84
|
+
continue
|
|
85
|
+
with os.fdopen(fd, "wb") as f:
|
|
86
|
+
f.write(data)
|
|
87
|
+
return bak
|
|
88
|
+
raise OSError(f"no free backup name for {path}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def atomic_write(path: Path, text: str | bytes, mode: int, *,
|
|
92
|
+
follow_symlinks: bool = True, overwrite: bool = True) -> None:
|
|
93
|
+
"""Replace path's content atomically: temp file in the same directory, fsync, rename.
|
|
94
|
+
By default symlinks are followed. Callers can replace the entry itself instead,
|
|
95
|
+
or require exclusive creation without overwriting any existing entry."""
|
|
96
|
+
path = Path(os.path.realpath(path)) if overwrite and follow_symlinks and Path(path).is_symlink() else Path(path)
|
|
97
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
98
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
99
|
+
try:
|
|
100
|
+
with os.fdopen(fd, "wb" if isinstance(text, bytes) else "w") as f:
|
|
101
|
+
f.write(text)
|
|
102
|
+
f.flush()
|
|
103
|
+
os.fsync(f.fileno())
|
|
104
|
+
os.chmod(tmp, mode)
|
|
105
|
+
if overwrite:
|
|
106
|
+
os.replace(tmp, path)
|
|
107
|
+
else:
|
|
108
|
+
os.link(tmp, path)
|
|
109
|
+
os.unlink(tmp)
|
|
110
|
+
except BaseException:
|
|
111
|
+
with contextlib.suppress(FileNotFoundError):
|
|
112
|
+
os.unlink(tmp)
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_atomic_write = atomic_write # the pre-0.2 name; kept for one release
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _hand_written(text: str) -> list[str]:
|
|
120
|
+
return [ln for ln in without_block(text).splitlines() if ln.strip()]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _needs_backup(c: Change) -> bool:
|
|
124
|
+
if c.old is None or is_generated(c.old):
|
|
125
|
+
return False
|
|
126
|
+
# Already managed and only our block changed: nothing hand-written is at risk.
|
|
127
|
+
return not (has_block(c.old) and _hand_written(c.old) == _hand_written(c.new or ""))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply(changes: list[Change]) -> None:
|
|
131
|
+
for c in changes:
|
|
132
|
+
if c.action == "remove":
|
|
133
|
+
c.path.unlink()
|
|
134
|
+
elif c.action == "chmod":
|
|
135
|
+
os.chmod(c.path, c.mode)
|
|
136
|
+
else:
|
|
137
|
+
if _needs_backup(c):
|
|
138
|
+
print(f" backup {_backup(c.path)}")
|
|
139
|
+
atomic_write(c.path, c.new, c.mode)
|