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/pin.py ADDED
@@ -0,0 +1,89 @@
1
+ """Copy a repo's identity into its own .git directory, for containers that don't see ~/.gitconfig's includes.
2
+
3
+ VS Code Dev Containers copy ~/.gitconfig into the container but not the files it includes, so the
4
+ includeIf identities are lost there. The repo's .git directory is mounted with the workspace, so a
5
+ plain [user] file included from .git/config carries the name and email along.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import stat
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ from . import files, render, resolve
14
+ from .config import ConfigError, check
15
+
16
+ FILE = "git-identities" # in the repo's common git dir; .git/config includes it by this relative path
17
+
18
+
19
+ def git(repo: str, *args: str, ok: tuple[int, ...] = (0,)) -> str:
20
+ r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
21
+ if r.returncode not in ok:
22
+ raise ConfigError(r.stderr.strip() or f"git {' '.join(args)} failed ({r.returncode})")
23
+ return r.stdout.strip()
24
+
25
+
26
+ def _pin_file(repo: str) -> Path:
27
+ return Path(git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir")) / FILE
28
+
29
+
30
+ def _resolved(repo: str, key: str, pin_path: Path, base: Path) -> str:
31
+ """key's value, if it comes from a file we generated (not, say, a hand-written ~/.gitconfig in a container)."""
32
+ entries = git(repo, "config", "-z", "--show-origin", "--get-all", key, ok=(0, 1)).split("\0")
33
+ origin, value, src = "", "", None
34
+ for found_origin, found_value in zip(entries[::2], entries[1::2]):
35
+ found_src = (base / found_origin[len("file:"):]).resolve() if found_origin.startswith("file:") else None
36
+ if found_src == pin_path.resolve():
37
+ continue
38
+ origin, value, src = found_origin, found_value, found_src
39
+ check(value, f"git resolves no {key} for this repo; nothing to pin")
40
+ check(src is not None and src.is_file() and render.is_generated(src.read_text()),
41
+ f"{key} comes from {origin}, not from gitpro; run 'gitpro apply', "
42
+ "and run pin on the host, not inside the container")
43
+ return value
44
+
45
+
46
+ def unpin(repo: str, *, dry_run: bool = False) -> bool:
47
+ """Remove the pin; return whether there was one."""
48
+ path = _pin_file(repo)
49
+ # --get-all exits 1 when the value is absent: not pinned.
50
+ had = git(repo, "config", "--local", "--fixed-value", "--get-all", "include.path", FILE, ok=(0, 1)) != ""
51
+ if dry_run:
52
+ return had or (path.is_file() and render.is_generated(path.read_text()))
53
+ if had:
54
+ git(repo, "config", "--local", "--fixed-value", "--unset-all", "include.path", FILE)
55
+ if not git(repo, "config", "--local", "--get-regexp", r"^include\.", ok=(0, 1)):
56
+ # Older git leaves the emptied [include] header behind; newer git already dropped it (exit 128).
57
+ git(repo, "config", "--local", "--remove-section", "include", ok=(0, 128))
58
+ if path.is_file() and render.is_generated(path.read_text()):
59
+ path.unlink()
60
+ had = True
61
+ return had
62
+
63
+
64
+ def pin(repo: str, *, dry_run: bool = False) -> tuple[str, str, Path]:
65
+ """Write the identity git resolves for repo now (name, email) into the repo; return them."""
66
+ path = _pin_file(repo)
67
+ check(not path.is_symlink(), f"{path}: pin file must not be a symlink; replace it with a regular file")
68
+ previous = path.read_bytes() if path.exists() else None
69
+ check(previous is None or render.is_generated(previous.decode()), f"{path} exists and was not written by us")
70
+ base = resolve._need_layout(Path(repo)).base
71
+ name, email = (_resolved(repo, key, path, base) for key in ("user.name", "user.email"))
72
+ if dry_run:
73
+ return name, email, path
74
+ included = git(repo, "config", "--local", "--fixed-value", "--get-all", "include.path", FILE, ok=(0, 1)) != ""
75
+ previous_mode = stat.S_IMODE(path.stat().st_mode) if previous is not None else 0o600
76
+ mode = previous_mode & 0o755
77
+ text = (f"{render.HEADER}\n# Re-run 'gitpro pin' after changing the config.\n"
78
+ f"[user]\n{render.INDENT}name = {render.gval(name)}\n{render.INDENT}email = {render.gval(email)}\n")
79
+ files.atomic_write(path, text, mode)
80
+ if not included:
81
+ try:
82
+ git(repo, "config", "--local", "--add", "include.path", FILE)
83
+ except BaseException:
84
+ if previous is None:
85
+ path.unlink()
86
+ else:
87
+ files.atomic_write(path, previous, previous_mode)
88
+ raise
89
+ return name, email, path
gitpro/plan.py ADDED
@@ -0,0 +1,199 @@
1
+ """Desired state: every managed file with its full content. Reads the disk, never writes."""
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from . import render
13
+ from .config import HOOKS_DIR, Config, Default, Identity, check, signs
14
+
15
+ MANAGED_GITCONFIG = ".gitconfig-identities"
16
+ GUARD_GITCONFIG = ".gitconfig-gitpro-guard" # core.hooksPath for the guard hooks (06)
17
+ MANAGED_SSH = "config.d/git-identities" # relative to ~/.ssh, as ssh's Include expects
18
+ ALLOWED_SIGNERS = ".config/git/allowed_signers"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Env:
23
+ home: Path
24
+
25
+ def expand(self, p: str) -> Path:
26
+ return self.home / p[2:] if p.startswith("~/") else Path(p)
27
+
28
+ def tilde(self, p: Path) -> str:
29
+ if self.home == Path.home().resolve() and self.home in p.parents:
30
+ return "~/" + str(p.relative_to(self.home))
31
+ return str(p)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Target:
36
+ path: Path
37
+ text: str | None # None: remove the file
38
+ mode: int = 0o644
39
+ strict: bool = False # force mode; otherwise keep the existing mode minus group/world write
40
+
41
+
42
+ def _read(p: Path) -> str:
43
+ return p.read_text() if p.exists() else ""
44
+
45
+
46
+ def _signers(d: Default, ids: list[Identity], env: Env) -> list[tuple[str, str]] | None:
47
+ pairs = [(d.email, d.ssh_key)] if d.sign else []
48
+ pairs += [(i.email, i.ssh_key or d.ssh_key) for i in ids if signs(i, d)]
49
+ if not pairs:
50
+ return None
51
+ entries = []
52
+ for email, key in dict.fromkeys(pairs):
53
+ pub = render.pub_path(env.expand(key))
54
+ if pub.exists():
55
+ entries.append((email, " ".join(pub.read_text().split()[:2])))
56
+ return entries
57
+
58
+
59
+ # ---------------------------------------------------------------- guard hooks (06)
60
+
61
+ def gitpro_bin() -> str:
62
+ """The installing gitpro, baked into the hook scripts as the last fallback ("" when unknown)."""
63
+ beside = Path(sys.executable).parent / "gitpro"
64
+ if beside.is_file() and os.access(beside, os.X_OK):
65
+ return str(beside)
66
+ arg0 = Path(sys.argv[0]) if sys.argv and sys.argv[0] else None
67
+ if arg0 is not None and arg0.name == "gitpro" and os.access(arg0, os.X_OK):
68
+ return str(arg0.resolve())
69
+ return ""
70
+
71
+
72
+ @functools.lru_cache(maxsize=1)
73
+ def hook_timeout() -> str:
74
+ """`timeout --foreground 30` when this machine's timeout(1) takes --foreground (GNU coreutils), else ""
75
+ (busybox and macOS: the check runs unbounded; every git call inside it has its own 5 s limit)."""
76
+ if not shutil.which("timeout"):
77
+ return ""
78
+ try:
79
+ ok = subprocess.run(["timeout", "--foreground", "5", "true"], capture_output=True,
80
+ stdin=subprocess.DEVNULL, timeout=10).returncode == 0
81
+ except (OSError, subprocess.SubprocessError):
82
+ ok = False
83
+ return "timeout --foreground 30" if ok else ""
84
+
85
+
86
+ def guard_paths(env: Env) -> list[Path]:
87
+ """Every file the guard owns: the hook scripts, the README, ~/.gitconfig-gitpro-guard."""
88
+ hooks = env.home / HOOKS_DIR
89
+ return [*(hooks / n for n in render.HOOK_NAMES), hooks / "README", env.home / GUARD_GITCONFIG]
90
+
91
+
92
+ def guard_manifest(env: Env) -> list[str]:
93
+ return sorted(env.tilde(p) for p in guard_paths(env))
94
+
95
+
96
+ def guard_include(env: Env) -> str:
97
+ return env.tilde(env.home / GUARD_GITCONFIG)
98
+
99
+
100
+ def guard_installed(env: Env) -> bool:
101
+ """`guard install` ran: ~/.gitconfig-gitpro-guard exists and gitpro wrote it."""
102
+ p = env.home / GUARD_GITCONFIG
103
+ return p.is_file() and render.is_generated(_read(p))
104
+
105
+
106
+ def guard_wanted(cfg: Config, env: Env) -> bool:
107
+ return bool(cfg.guard.hooks) or guard_installed(env)
108
+
109
+
110
+ def prune_hooks_dir(env: Env) -> None:
111
+ """Remove ~/.config/gitpro/hooks after an uninstall, only when nothing is left in it."""
112
+ try:
113
+ (env.home / HOOKS_DIR).rmdir()
114
+ except OSError:
115
+ pass
116
+
117
+
118
+ def guard_targets(cfg: Config, env: Env) -> list[Target]:
119
+ hooks = env.home / HOOKS_DIR
120
+ chain_dir = env.expand(cfg.guard.chain_to) if cfg.guard.chain_to else None
121
+ chain = str(chain_dir) if chain_dir is not None and chain_dir.is_dir() else None # missing: no chain
122
+ gp = gitpro_bin()
123
+ out = [Target(hooks / n, render.hook_script(n, chain, gp, hook_timeout()) if n in render.GUARD_HOOKS
124
+ else render.hook_stub(n, chain), 0o700, strict=True) for n in render.HOOK_NAMES]
125
+ return out + [Target(hooks / "README", render.hooks_readme(), 0o600),
126
+ Target(env.home / GUARD_GITCONFIG, render.hooks_gitconfig(hooks), 0o600, strict=True)]
127
+
128
+
129
+ def build(cfg: Config, env: Env) -> tuple[list[Target], list[Path]]:
130
+ """Return (targets, stale generated files to remove). Callers holding (Default, list[Identity])
131
+ wrap them: build(Config(d, ids), env)."""
132
+ d, ids = cfg.default, list(cfg.identities)
133
+ reserved = [env.home / name for name in (MANAGED_GITCONFIG, ".gitconfig", ALLOWED_SIGNERS,
134
+ ".ssh/config", ".ssh/" + MANAGED_SSH)]
135
+ reserved += guard_paths(env)
136
+ if cfg.path is not None:
137
+ reserved.append(cfg.path)
138
+ used = {p.resolve(): str(p) for p in reserved}
139
+ for i in ids:
140
+ path = env.expand(i.file).resolve()
141
+ check(env.home in path.parents, f"identity {i.id!r}: file must be inside {env.home}")
142
+ for other, owner in used.items():
143
+ check(path != other and path not in other.parents and other not in path.parents,
144
+ f"identity {i.id!r}: file {i.file} conflicts with {owner}")
145
+ used[path] = f"identity {i.id!r} file {i.file}"
146
+
147
+ extra = [Target(env.expand(i.file), render.identity_gitconfig(i, d, env.expand)) for i in ids]
148
+ signers = _signers(d, ids, env)
149
+ signers_path = env.home / ALLOWED_SIGNERS
150
+ if signers is not None:
151
+ extra.append(Target(signers_path, render.allowed_signers(signers)))
152
+ manifest = sorted(env.tilde(t.path) for t in extra)
153
+
154
+ managed = env.home / MANAGED_GITCONFIG
155
+ gitconfig = env.home / ".gitconfig"
156
+ ssh_dir = env.home / ".ssh"
157
+ managed_text = render.managed_gitconfig(
158
+ d, ids, env.expand, str(signers_path) if signers is not None else None, manifest)
159
+ guard: list[Target] = []
160
+ if guard_wanted(cfg, env):
161
+ from .guard import check_chain_to, check_foreign
162
+ check_chain_to(cfg, env, missing_ok=True) # every render: refuses an unsafe dir, warns about a missing one
163
+ if not guard_installed(env): # first install through [guard] hooks = true: same refusal as guard install
164
+ check_foreign(cfg, env.home)
165
+ guard = guard_targets(cfg, env)
166
+ managed_text = render.with_guard_include(managed_text, guard_include(env), guard_manifest(env))
167
+ manifest = sorted(manifest + guard_manifest(env))
168
+ targets = [
169
+ Target(managed, managed_text),
170
+ *extra,
171
+ *guard,
172
+ # Last in ~/.gitconfig so it overrides anything hand-written above it.
173
+ Target(gitconfig, render.with_block(
174
+ _read(gitconfig), f"[include]\n{render.INDENT}path = {env.tilde(managed)}", first=False, where="~/.gitconfig")),
175
+ Target(ssh_dir / MANAGED_SSH, render.ssh_config(d, ids), 0o600, strict=True),
176
+ # First in ~/.ssh/config; "Match all" stops a Host block prepended above it from scoping the Include.
177
+ Target(ssh_dir / "config", render.with_block(
178
+ _read(ssh_dir / "config"), f"Match all\nInclude {MANAGED_SSH}", first=True,
179
+ where="~/.ssh/config"), 0o600, strict=True),
180
+ ]
181
+ live_paths = {t.path.resolve() for t in targets}
182
+ stale = [env.expand(p) for p in render.manifest_of(_read(managed))
183
+ if p not in manifest and env.expand(p).resolve() not in live_paths]
184
+ return targets, stale
185
+
186
+
187
+ def teardown(env: Env) -> tuple[list[Target], list[Path]]:
188
+ """Undo build without needing the config: (files to strip our block from, generated files)."""
189
+ managed = env.home / MANAGED_GITCONFIG
190
+ targets = []
191
+ for path, where in ((env.home / ".gitconfig", "~/.gitconfig"), (env.home / ".ssh/config", "~/.ssh/config")):
192
+ text = _read(path)
193
+ if render.has_block(text):
194
+ # A file holding nothing but our block was created by apply: remove it (never a symlink's target).
195
+ rest = render.strip_block(text, where)
196
+ targets.append(Target(path, rest if rest or path.is_symlink() else None))
197
+ generated = [env.expand(p) for p in render.manifest_of(_read(managed))]
198
+ # The guard files too, even when the manifest lost them; diff removes only files gitpro generated.
199
+ return targets, [managed, env.home / ".ssh" / MANAGED_SSH, *generated, *guard_paths(env)]