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