compforge-repokit 0.15.0__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.
- compforge_repokit-0.15.0.dist-info/METADATA +114 -0
- compforge_repokit-0.15.0.dist-info/RECORD +37 -0
- compforge_repokit-0.15.0.dist-info/WHEEL +4 -0
- repokit/__init__.py +40 -0
- repokit/_git.py +177 -0
- repokit/_inspect.py +70 -0
- repokit/_language.py +23 -0
- repokit/_layout.py +187 -0
- repokit/_process.py +135 -0
- repokit/_staging.py +59 -0
- repokit/budget.py +40 -0
- repokit/dependencies.py +378 -0
- repokit/forge/__init__.py +13 -0
- repokit/forge/_http_state.py +92 -0
- repokit/forge/_page.py +34 -0
- repokit/forge/_rest.py +233 -0
- repokit/forge/creation.py +88 -0
- repokit/forge/github.py +440 -0
- repokit/forge/gitlab.py +407 -0
- repokit/forge/model.py +380 -0
- repokit/forge/observation/__init__.py +9 -0
- repokit/forge/observation/context.py +255 -0
- repokit/forge/observation/model.py +40 -0
- repokit/forge/observation/service.py +283 -0
- repokit/forge/observation/store.py +136 -0
- repokit/git.py +296 -0
- repokit/git_index.py +275 -0
- repokit/git_state.py +447 -0
- repokit/languages.json +1115 -0
- repokit/model.py +59 -0
- repokit/py.typed +0 -0
- repokit/remote.py +41 -0
- repokit/repository.py +84 -0
- repokit/snapshot.py +275 -0
- repokit/stash.py +66 -0
- repokit/tree.py +191 -0
- repokit/validation.py +508 -0
repokit/git_state.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Git checkout, branch and remote observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from stat import S_ISDIR, S_ISREG
|
|
9
|
+
from time import monotonic
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
from . import git as gitcmd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def git_path(repo_dir: str | Path, name: str) -> Path:
|
|
16
|
+
"""Resolve an absolute Git metadata path, even if it does not exist yet.
|
|
17
|
+
|
|
18
|
+
Git decides whether metadata is checkout-local or shared. Failed reads raise;
|
|
19
|
+
path whitespace is preserved, including a trailing newline in the name.
|
|
20
|
+
"""
|
|
21
|
+
result = gitcmd.git(
|
|
22
|
+
repo_dir, "rev-parse", "--path-format=absolute", "--git-path", name, raw=True
|
|
23
|
+
)
|
|
24
|
+
if not result.ok or not result.out:
|
|
25
|
+
raise OSError(f"cannot resolve Git metadata path {name!r}: {result.err}")
|
|
26
|
+
return Path(result.out.removesuffix("\n"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rebase_in_progress(repo_dir: str | Path) -> bool:
|
|
30
|
+
"""Observe either Git rebase backend in this checkout; failed reads raise."""
|
|
31
|
+
# git am also owns rebase-apply; only its rebasing marker identifies a rebase.
|
|
32
|
+
for name, matches in (("rebase-merge", S_ISDIR), ("rebase-apply/rebasing", S_ISREG)):
|
|
33
|
+
path = git_path(repo_dir, name)
|
|
34
|
+
try:
|
|
35
|
+
if matches(path.stat().st_mode):
|
|
36
|
+
return True
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
pass
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_current_branch(repo_dir: str | Path) -> str | None:
|
|
43
|
+
"""Current branch, including unborn branches; None means detached HEAD."""
|
|
44
|
+
r = gitcmd.git(repo_dir, "branch", "--show-current")
|
|
45
|
+
if not r.ok:
|
|
46
|
+
raise OSError(f"cannot read current branch: {r.err}")
|
|
47
|
+
return r.out or None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ahead_behind(repo_dir: str | Path, target: str) -> tuple[int, int] | None:
|
|
51
|
+
head = rev_parse(repo_dir, "HEAD")
|
|
52
|
+
other = rev_parse(repo_dir, target)
|
|
53
|
+
if not head or not other:
|
|
54
|
+
return None
|
|
55
|
+
# Resolve both endpoints before counting; missing endpoints are distinct from failed counts.
|
|
56
|
+
r = gitcmd.git(repo_dir, "rev-list", "--count", "--left-right", f"{head}...{other}")
|
|
57
|
+
values = r.out.split()
|
|
58
|
+
if not r.ok or len(values) != 2 or not all(v.isdecimal() for v in values):
|
|
59
|
+
raise OSError(f"cannot count divergence from {target}: {r.err or r.out}")
|
|
60
|
+
return int(values[0]), int(values[1])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_ahead_behind(repo_dir: str | Path, target: str = "main") -> tuple[int, int] | None:
|
|
64
|
+
"""(ahead, behind) vs origin/target; None for missing endpoints, failed reads raise."""
|
|
65
|
+
return _ahead_behind(repo_dir, f"refs/remotes/origin/{target}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WorkspaceStatus(TypedDict):
|
|
69
|
+
dirty: bool
|
|
70
|
+
modified_count: int
|
|
71
|
+
untracked_count: int
|
|
72
|
+
complete: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_workspace_status(repo_dir: str | Path) -> WorkspaceStatus:
|
|
76
|
+
"""dict with dirty / modified_count / untracked_count."""
|
|
77
|
+
r = gitcmd.git(repo_dir, "status", "--porcelain")
|
|
78
|
+
if not r.ok:
|
|
79
|
+
return {"dirty": False, "modified_count": 0, "untracked_count": 0, "complete": False}
|
|
80
|
+
lines = [ln for ln in r.out.split("\n") if ln.strip()]
|
|
81
|
+
modified = sum(1 for ln in lines if not ln.startswith("??"))
|
|
82
|
+
untracked = sum(1 for ln in lines if ln.startswith("??"))
|
|
83
|
+
return {
|
|
84
|
+
"dirty": bool(lines),
|
|
85
|
+
"modified_count": modified,
|
|
86
|
+
"untracked_count": untracked,
|
|
87
|
+
"complete": True,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def target_exists(repo_dir: str | Path, target: str = "main") -> bool:
|
|
92
|
+
return bool(rev_parse(repo_dir, f"refs/remotes/origin/{target}"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def local_default_branch(repo_dir: str | Path, *, remote: str = "origin") -> str | None:
|
|
96
|
+
"""Read the locally cached remote HEAD branch without contacting the remote.
|
|
97
|
+
|
|
98
|
+
None means the symbolic reference is absent; Git failures raise OSError.
|
|
99
|
+
The cache may be stale. Refreshing it or choosing a fallback belongs to the caller.
|
|
100
|
+
"""
|
|
101
|
+
prefix = f"refs/remotes/{remote}/"
|
|
102
|
+
result = gitcmd.git(repo_dir, "symbolic-ref", "--quiet", f"{prefix}HEAD")
|
|
103
|
+
if result.rc == 1:
|
|
104
|
+
return None
|
|
105
|
+
if not result.ok:
|
|
106
|
+
raise OSError(f"cannot read default branch for remote {remote!r}: {result.err}")
|
|
107
|
+
if not result.out.startswith(prefix) or not result.out.removeprefix(prefix):
|
|
108
|
+
raise OSError(f"unexpected default branch reference for remote {remote!r}: {result.out!r}")
|
|
109
|
+
return result.out.removeprefix(prefix)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def refresh_remote_head(repo_dir: str | Path, timeout: int = 5) -> bool:
|
|
113
|
+
"""Refresh local origin/HEAD from the remote default branch via one network round-trip
|
|
114
|
+
(`git fetch` never touches origin/HEAD). Best-effort. Used as the no-token fallback when
|
|
115
|
+
the forge can't supply the default branch (see context.repo._resolve_default_branch)."""
|
|
116
|
+
return gitcmd.git(repo_dir, "remote", "set-head", "origin", "--auto", timeout=timeout).ok
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_local_default_head(repo_dir: str | Path, branch: str) -> bool:
|
|
120
|
+
"""Point local `refs/remotes/origin/HEAD` at `branch` — purely local, no network.
|
|
121
|
+
|
|
122
|
+
Keeps the git-side cache consistent with an authoritative value resolved elsewhere (the
|
|
123
|
+
forge), so every `local_default_target` caller (gcampr, run_review, …) reads the same answer
|
|
124
|
+
without each re-querying. Best-effort.
|
|
125
|
+
"""
|
|
126
|
+
if not branch:
|
|
127
|
+
return False
|
|
128
|
+
return gitcmd.git(
|
|
129
|
+
repo_dir, "symbolic-ref", "refs/remotes/origin/HEAD", f"refs/remotes/origin/{branch}"
|
|
130
|
+
).ok
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_worktree_metadata(repo_dir: str | Path) -> tuple[bool, str, str | None]:
|
|
134
|
+
"""Checkout metadata derived from the same topology as checkout_info.
|
|
135
|
+
|
|
136
|
+
Failed reads raise; an unknown primary location is retained by checkout_info.
|
|
137
|
+
"""
|
|
138
|
+
info = checkout_info(repo_dir)
|
|
139
|
+
if not info.linked:
|
|
140
|
+
return False, "", None
|
|
141
|
+
primary = next((entry for entry in list_checkouts(repo_dir) if entry.primary), None)
|
|
142
|
+
return True, info.common_dir, primary.branch if primary else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_linked_worktree(repo_dir: str | Path) -> bool:
|
|
146
|
+
"""True for a linked checkout; failed observations raise."""
|
|
147
|
+
return checkout_info(repo_dir).linked
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_head_sha(repo_dir: str | Path) -> str:
|
|
151
|
+
"""Current HEAD object; empty for unborn HEAD, failed reads raise."""
|
|
152
|
+
return rev_parse(repo_dir, "HEAD")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def rev_parse(repo_dir: str | Path, ref: str) -> str:
|
|
156
|
+
"""Resolve a revision; empty means absent, not a failed Git observation."""
|
|
157
|
+
r = gitcmd.git(repo_dir, "rev-parse", "--verify", "--quiet", "--end-of-options", ref)
|
|
158
|
+
if r.ok and r.out:
|
|
159
|
+
return r.out
|
|
160
|
+
# Quiet verification uses 1 for absent revisions. Fatal repository errors,
|
|
161
|
+
# timeouts and diagnostics must not be projected as absence.
|
|
162
|
+
if r.rc == 1 and not r.err:
|
|
163
|
+
return ""
|
|
164
|
+
raise OSError(f"cannot resolve revision {ref!r}: {r.err or r.out}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_ancestor(repo_dir: str | Path, ancestor: str | None, descendant: str | None) -> bool:
|
|
168
|
+
"""Whether ancestor is reachable; invalid objects and failed reads raise."""
|
|
169
|
+
if not ancestor or not descendant:
|
|
170
|
+
raise ValueError("ancestry requires two revisions")
|
|
171
|
+
# Even identical inputs must be checked: textual equality proves no object exists.
|
|
172
|
+
r = gitcmd.git(repo_dir, "merge-base", "--is-ancestor", "--", ancestor, descendant)
|
|
173
|
+
if r.rc in (0, 1):
|
|
174
|
+
return r.rc == 0
|
|
175
|
+
raise OSError(f"cannot compare ancestry {ancestor!r} -> {descendant!r}: {r.err}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_upstream_ahead_behind(repo_dir: str | Path) -> tuple[int, int] | None:
|
|
179
|
+
"""Divergence from the configured upstream; None when branch/upstream is absent."""
|
|
180
|
+
branch = get_current_branch(repo_dir)
|
|
181
|
+
if branch is None:
|
|
182
|
+
return None
|
|
183
|
+
# for-each-ref returns a successful empty value for no upstream or an unborn branch.
|
|
184
|
+
ref = f"refs/heads/{branch}"
|
|
185
|
+
r = gitcmd.git(repo_dir, "for-each-ref", "--format=%(refname)%00%(upstream)", ref)
|
|
186
|
+
if not r.ok:
|
|
187
|
+
raise OSError(f"cannot read upstream for {branch!r}: {r.err}")
|
|
188
|
+
upstream = next(
|
|
189
|
+
(line.split("\0", 1)[1] for line in r.out.splitlines() if line.startswith(ref + "\0")), ""
|
|
190
|
+
)
|
|
191
|
+
return _ahead_behind(repo_dir, upstream) if upstream else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ls_remote_tips(
|
|
195
|
+
repo_dir: str | Path, *branches: str, timeout: int = 5, remote: str = "origin"
|
|
196
|
+
) -> dict[str, str]:
|
|
197
|
+
"""Read `{branch: sha}` via `git ls-remote` without fetching objects.
|
|
198
|
+
|
|
199
|
+
`remote` is a remote name or URL, defaulting to origin. Empty means no matching
|
|
200
|
+
remote refs; failed reads raise OSError.
|
|
201
|
+
"""
|
|
202
|
+
if not branches:
|
|
203
|
+
return {}
|
|
204
|
+
r = gitcmd.git(repo_dir, "ls-remote", remote, *branches, timeout=timeout)
|
|
205
|
+
if not r.ok:
|
|
206
|
+
raise OSError(r.err or "cannot read remote branch tips")
|
|
207
|
+
tips: dict[str, str] = {}
|
|
208
|
+
for line in r.out.splitlines():
|
|
209
|
+
if "\t" not in line:
|
|
210
|
+
continue
|
|
211
|
+
sha, ref = line.split("\t", 1)
|
|
212
|
+
if ref.startswith("refs/heads/"):
|
|
213
|
+
tips[ref[len("refs/heads/") :]] = sha
|
|
214
|
+
return tips
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def list_worktrees(repo_dir: str | Path) -> list[tuple[str, str, str | None]]:
|
|
218
|
+
"""Registered worktrees as (path, SHA, branch); paths are NUL delimited.
|
|
219
|
+
|
|
220
|
+
The first entry may be a bare/separate Git directory, not a checkout. Use
|
|
221
|
+
checkout_info for topology. Query failures raise rather than imply an empty repo.
|
|
222
|
+
"""
|
|
223
|
+
r = gitcmd.git(repo_dir, "worktree", "list", "--porcelain", "-z")
|
|
224
|
+
if not r.ok:
|
|
225
|
+
raise OSError(r.err or "cannot list worktrees")
|
|
226
|
+
out = []
|
|
227
|
+
for block in r.out.split("\0\0"):
|
|
228
|
+
fields = dict(line.split(" ", 1) for line in block.split("\0") if " " in line)
|
|
229
|
+
if path := fields.get("worktree"):
|
|
230
|
+
branch = fields.get("branch")
|
|
231
|
+
out.append(
|
|
232
|
+
(
|
|
233
|
+
path,
|
|
234
|
+
fields.get("HEAD", ""),
|
|
235
|
+
branch.removeprefix("refs/heads/") if branch else None,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True)
|
|
242
|
+
class CheckoutInfo:
|
|
243
|
+
root: str
|
|
244
|
+
git_dir: str
|
|
245
|
+
common_dir: str
|
|
246
|
+
main_root: str | None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def linked(self) -> bool:
|
|
250
|
+
return self.git_dir != self.common_dir
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def checkout_info(repo_dir: str | Path) -> CheckoutInfo:
|
|
254
|
+
"""Observe checkout topology, without choosing a caller's state directory.
|
|
255
|
+
|
|
256
|
+
main_root is the checkout recoverable from shared Git metadata. It can be
|
|
257
|
+
unknown for separate Git directories: their original checkout has no backlink.
|
|
258
|
+
Non-checkouts and failed observations raise OSError.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def query(path: str | Path, flag: str) -> str:
|
|
262
|
+
result = gitcmd.git(path, "rev-parse", "--path-format=absolute", flag, raw=True)
|
|
263
|
+
if not result.ok or not result.out:
|
|
264
|
+
raise OSError(result.err or f"cannot resolve {flag}")
|
|
265
|
+
return str(Path(result.out.removesuffix("\n")).resolve())
|
|
266
|
+
|
|
267
|
+
root = query(repo_dir, "--show-toplevel")
|
|
268
|
+
if not (Path(root) / ".git").exists():
|
|
269
|
+
raise OSError("Git metadata directory is not a checkout")
|
|
270
|
+
gd = query(repo_dir, "--git-dir")
|
|
271
|
+
cd = query(repo_dir, "--git-common-dir")
|
|
272
|
+
entries = list_worktrees(repo_dir)
|
|
273
|
+
main = None
|
|
274
|
+
if entries:
|
|
275
|
+
try:
|
|
276
|
+
candidate = query(entries[0][0], "--show-toplevel")
|
|
277
|
+
# Git can treat a separate metadata directory as the work tree. Its
|
|
278
|
+
# lack of a .git entry distinguishes it from an actual checkout.
|
|
279
|
+
if (Path(candidate) / ".git").exists() and query(candidate, "--git-common-dir") == cd:
|
|
280
|
+
main = candidate
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
return CheckoutInfo(root, gd, cd, main)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class CheckoutEntry:
|
|
288
|
+
"""Registered checkout; path is unknown when only its metadata is locatable."""
|
|
289
|
+
|
|
290
|
+
path: str | None
|
|
291
|
+
head: str
|
|
292
|
+
branch: str | None
|
|
293
|
+
primary: bool
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def list_checkouts(repo_dir: str | Path) -> list[CheckoutEntry]:
|
|
297
|
+
"""Project Git registrations into checkout locations, retaining unknown paths.
|
|
298
|
+
|
|
299
|
+
A separate Git directory has no backlink to its primary checkout. When called
|
|
300
|
+
from that primary checkout we can identify it directly; from a linked checkout
|
|
301
|
+
its location stays unknown. Unknown must not be treated as an absent checkout.
|
|
302
|
+
"""
|
|
303
|
+
info = checkout_info(repo_dir)
|
|
304
|
+
entries = list_worktrees(repo_dir)
|
|
305
|
+
main = info.root if not info.linked else info.main_root
|
|
306
|
+
return [
|
|
307
|
+
CheckoutEntry(main if index == 0 else str(Path(path).resolve()), head, branch, index == 0)
|
|
308
|
+
for index, (path, head, branch) in enumerate(entries)
|
|
309
|
+
if head # Bare registration has no HEAD and is not a checkout.
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main_repo_root(repo_dir: str) -> str | None:
|
|
314
|
+
"""Verified main checkout, or None for an unknown location; failed reads raise."""
|
|
315
|
+
return checkout_info(repo_dir).main_root
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def list_local_branches(repo_dir: str | Path) -> list[tuple[str, str]]:
|
|
319
|
+
"""Every local branch as ``(name, head_sha)`` in stable name order.
|
|
320
|
+
|
|
321
|
+
A PR/MR can outlive its checkout, so lifecycle inventory starts from local
|
|
322
|
+
refs rather than from ``git worktree list``. The NUL separator avoids
|
|
323
|
+
parsing human-facing branch output.
|
|
324
|
+
"""
|
|
325
|
+
r = gitcmd.git(
|
|
326
|
+
repo_dir,
|
|
327
|
+
"for-each-ref",
|
|
328
|
+
"--sort=refname",
|
|
329
|
+
"--format=%(refname:short)%00%(objectname)",
|
|
330
|
+
"refs/heads",
|
|
331
|
+
)
|
|
332
|
+
if not r.ok:
|
|
333
|
+
raise OSError(r.err or "cannot list local branches")
|
|
334
|
+
out: list[tuple[str, str]] = []
|
|
335
|
+
for line in r.out.splitlines():
|
|
336
|
+
if "\x00" not in line:
|
|
337
|
+
continue
|
|
338
|
+
branch, sha = line.split("\x00", 1)
|
|
339
|
+
if branch and sha:
|
|
340
|
+
out.append((branch, sha))
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def fetch(repo_dir: str | Path, *refs: str, timeout: int = 8, remote: str = "origin") -> bool:
|
|
345
|
+
"""Run a bounded `git fetch <remote> [refs...]` at intentional refresh points.
|
|
346
|
+
|
|
347
|
+
`remote` is a remote name or URL, defaulting to origin. Supplied refspecs and
|
|
348
|
+
Git's remote configuration determine which local refs are updated.
|
|
349
|
+
Best-effort: a failed fetch returns False.
|
|
350
|
+
"""
|
|
351
|
+
return gitcmd.git(repo_dir, "fetch", remote, *refs, "--quiet", timeout=timeout).ok
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def find_checkout(directory: str | Path) -> str | None:
|
|
355
|
+
"""Find a physical checkout by its .git entry. None means no ancestor checkout.
|
|
356
|
+
|
|
357
|
+
Invalid paths, inaccessible/corrupt metadata and Git failures raise. Does not
|
|
358
|
+
interpret a bare metadata directory as a checkout or choose environment overrides.
|
|
359
|
+
"""
|
|
360
|
+
start = Path(directory).resolve(strict=True)
|
|
361
|
+
if not start.is_dir():
|
|
362
|
+
raise NotADirectoryError(str(start))
|
|
363
|
+
for candidate in (start, *start.parents):
|
|
364
|
+
# A metadata directory is not a source checkout, even inside one.
|
|
365
|
+
if candidate.name == ".git":
|
|
366
|
+
return None
|
|
367
|
+
try:
|
|
368
|
+
(candidate / ".git").lstat()
|
|
369
|
+
except FileNotFoundError:
|
|
370
|
+
continue
|
|
371
|
+
result = gitcmd.git(candidate, "rev-parse", "--show-toplevel", raw=True)
|
|
372
|
+
if not result.ok or not result.out:
|
|
373
|
+
raise OSError(result.err or "cannot discover checkout")
|
|
374
|
+
root = result.out.removesuffix("\n")
|
|
375
|
+
if Path(root).resolve() != candidate:
|
|
376
|
+
raise OSError("Git environment does not match discovered checkout")
|
|
377
|
+
return root
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def validate_branch_name(repo_dir: str | Path, name: str) -> None:
|
|
382
|
+
"""Require a literal branch name; shorthand such as @{-1} is not a name."""
|
|
383
|
+
result = gitcmd.git(repo_dir, "check-ref-format", "--branch", name)
|
|
384
|
+
if not result.ok or result.out != name:
|
|
385
|
+
raise ValueError(f"invalid branch name {name!r}: {result.err or result.out}")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass(frozen=True)
|
|
389
|
+
class Commit:
|
|
390
|
+
oid: str
|
|
391
|
+
subject: str
|
|
392
|
+
body: str
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def commits(
|
|
396
|
+
repo_dir: str | Path, base: str, head: str = "HEAD", *, include_merges: bool = True
|
|
397
|
+
) -> list[Commit]:
|
|
398
|
+
"""Commits reachable from head but not base; failed/missing endpoints raise."""
|
|
399
|
+
resolved = [rev_parse(repo_dir, ref + "^{commit}") for ref in (base, head)]
|
|
400
|
+
if not all(resolved):
|
|
401
|
+
raise OSError(f"cannot read commits for {base!r}..{head!r}: missing endpoint")
|
|
402
|
+
result = gitcmd.git(
|
|
403
|
+
repo_dir,
|
|
404
|
+
"log",
|
|
405
|
+
"-z",
|
|
406
|
+
"--format=%H%x00%s%x00%b",
|
|
407
|
+
*([] if include_merges else ["--no-merges"]),
|
|
408
|
+
f"{resolved[0]}..{resolved[1]}",
|
|
409
|
+
"--",
|
|
410
|
+
raw=True,
|
|
411
|
+
)
|
|
412
|
+
if not result.ok:
|
|
413
|
+
raise OSError(f"cannot read commit history: {result.err}")
|
|
414
|
+
fields = result.out.removesuffix("\0").split("\0") if result.out else []
|
|
415
|
+
if len(fields) % 3:
|
|
416
|
+
raise OSError("invalid Git commit history")
|
|
417
|
+
return [Commit(*fields[i : i + 3]) for i in range(0, len(fields), 3)]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def merge_base(repo_dir: str | Path, left: str, right: str) -> str | None:
|
|
421
|
+
"""Common ancestor; None only for unrelated histories, errors propagate."""
|
|
422
|
+
result = gitcmd.git(repo_dir, "merge-base", "--", left, right)
|
|
423
|
+
if result.rc == 1 and not result.err:
|
|
424
|
+
return None
|
|
425
|
+
if not result.ok or not result.out:
|
|
426
|
+
raise OSError(f"cannot read merge base of {left!r} and {right!r}: {result.err}")
|
|
427
|
+
return result.out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def checkout_at(directory: str | Path) -> CheckoutInfo | None:
|
|
431
|
+
"""Exact checkout topology, or None for a directory without a .git entry.
|
|
432
|
+
|
|
433
|
+
Failed metadata reads propagate. No ancestor search or application state policy.
|
|
434
|
+
"""
|
|
435
|
+
root = Path(directory).resolve()
|
|
436
|
+
try:
|
|
437
|
+
marker = (root / ".git").stat()
|
|
438
|
+
except FileNotFoundError:
|
|
439
|
+
return None
|
|
440
|
+
return _checkout_at(
|
|
441
|
+
str(root), marker.st_dev, marker.st_ino, marker.st_mtime_ns, int(monotonic())
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@lru_cache(maxsize=64)
|
|
446
|
+
def _checkout_at(root: str, device: int, inode: int, modified: int, second: int) -> CheckoutInfo:
|
|
447
|
+
return checkout_info(root)
|