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