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