context-loader 0.1.8__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.
context_loader/git.py ADDED
@@ -0,0 +1,348 @@
1
+ """Read the bounded local Git state needed by the context loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+
12
+ GIT_EXECUTABLE = "/usr/bin/git"
13
+ GIT_TIMEOUT_SECONDS = 30
14
+ MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024
15
+ MAX_RECENT_COMMITS = 8
16
+
17
+ _OID_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
18
+ _GIT_PREFIX = (
19
+ "--no-pager",
20
+ "--no-optional-locks",
21
+ "--literal-pathspecs",
22
+ "-c",
23
+ "color.ui=false",
24
+ "-c",
25
+ "core.attributesFile=/dev/null",
26
+ "-c",
27
+ "core.fsmonitor=false",
28
+ "-c",
29
+ "core.hooksPath=/dev/null",
30
+ "-c",
31
+ "diff.external=",
32
+ "-c",
33
+ "diff.trustExitCode=false",
34
+ "-c",
35
+ "i18n.logOutputEncoding=UTF-8",
36
+ "-c",
37
+ "log.showSignature=false",
38
+ )
39
+ _GIT_ENVIRONMENT = MappingProxyType(
40
+ {
41
+ "GIT_ATTR_NOSYSTEM": "1",
42
+ "GIT_CONFIG_COUNT": "0",
43
+ "GIT_CONFIG_GLOBAL": "/dev/null",
44
+ "GIT_CONFIG_NOSYSTEM": "1",
45
+ "GIT_CONFIG_SYSTEM": "/dev/null",
46
+ "GIT_NO_LAZY_FETCH": "1",
47
+ "GIT_NO_REPLACE_OBJECTS": "1",
48
+ "GIT_OPTIONAL_LOCKS": "0",
49
+ "GIT_PAGER": "cat",
50
+ "GIT_TERMINAL_PROMPT": "0",
51
+ "HOME": "/nonexistent",
52
+ "LANG": "C.UTF-8",
53
+ "LC_ALL": "C.UTF-8",
54
+ "PATH": "/usr/bin:/bin",
55
+ "PAGER": "cat",
56
+ "TERM": "dumb",
57
+ "XDG_CONFIG_HOME": "/nonexistent",
58
+ }
59
+ )
60
+
61
+
62
+ class ContextLoaderError(Exception):
63
+ """A safe, user-facing context-loader failure."""
64
+
65
+ def __init__(self, message: str, *, exit_code: int = 1) -> None:
66
+ super().__init__(message)
67
+ self.exit_code = exit_code
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class GitResult:
72
+ stdout: bytes
73
+ returncode: int
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class WorkingTreeChange:
78
+ status: str
79
+ path: str
80
+ sort_key: bytes
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class RecentCommit:
85
+ object_id: str
86
+ author_date: str
87
+ subject: str
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class RepositoryState:
92
+ repository: Path
93
+ branch: str
94
+ head: str
95
+ upstream: str
96
+ ahead_behind: str
97
+ changes: tuple[WorkingTreeChange, ...]
98
+ commits: tuple[RecentCommit, ...]
99
+
100
+ @property
101
+ def worktree(self) -> str:
102
+ return "dirty" if self.changes else "clean"
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class RepositoryLocation:
107
+ requested_path: Path
108
+ canonical_root: Path
109
+
110
+
111
+ def _run_git(repo: Path, arguments: tuple[str, ...], *, check: bool = True) -> GitResult:
112
+ argv = [GIT_EXECUTABLE, *_GIT_PREFIX, "-C", os.fspath(repo), *arguments]
113
+ try:
114
+ completed = subprocess.run( # noqa: S603 - executable and every command are fixed here.
115
+ argv,
116
+ cwd="/",
117
+ env=dict(_GIT_ENVIRONMENT),
118
+ stdin=subprocess.DEVNULL,
119
+ capture_output=True,
120
+ timeout=GIT_TIMEOUT_SECONDS,
121
+ check=False,
122
+ )
123
+ except (OSError, subprocess.TimeoutExpired) as exc:
124
+ raise ContextLoaderError("unable to read repository Git state") from exc
125
+ if len(completed.stdout) > MAX_GIT_OUTPUT_BYTES:
126
+ raise ContextLoaderError("repository Git output exceeded the safety limit")
127
+ if check and completed.returncode != 0:
128
+ raise ContextLoaderError("unable to read repository Git state")
129
+ return GitResult(completed.stdout, completed.returncode)
130
+
131
+
132
+ def _without_one_line_ending(raw: bytes) -> bytes:
133
+ if raw.endswith(b"\n"):
134
+ return raw[:-1]
135
+ return raw
136
+
137
+
138
+ def _decode_path(raw: bytes) -> str:
139
+ return os.fsdecode(raw)
140
+
141
+
142
+ def _decode_text(raw: bytes) -> str:
143
+ return raw.decode("utf-8", errors="backslashreplace")
144
+
145
+
146
+ def discover_repository(
147
+ raw_path: str | os.PathLike[str], *, require_canonical_root: bool = False
148
+ ) -> RepositoryLocation:
149
+ """Resolve an absolute directory and discover its non-bare Git worktree root."""
150
+ candidate = Path(raw_path)
151
+ if not candidate.is_absolute():
152
+ raise ContextLoaderError("--repo must be an absolute path", exit_code=2)
153
+ try:
154
+ canonical = candidate.resolve(strict=True)
155
+ except (OSError, RuntimeError):
156
+ raise ContextLoaderError("--repo must name an existing directory", exit_code=2) from None
157
+ if not canonical.is_dir():
158
+ raise ContextLoaderError("--repo must name an existing directory", exit_code=2)
159
+
160
+ bare = _run_git(canonical, ("rev-parse", "--is-bare-repository"), check=False)
161
+ root = _run_git(
162
+ canonical,
163
+ ("rev-parse", "--path-format=absolute", "--show-toplevel"),
164
+ check=False,
165
+ )
166
+ if bare.returncode != 0 or root.returncode != 0:
167
+ raise ContextLoaderError(
168
+ "--repo must be the canonical root of a non-bare Git worktree", exit_code=2
169
+ )
170
+ if _without_one_line_ending(bare.stdout) != b"false":
171
+ raise ContextLoaderError(
172
+ "--repo must be the canonical root of a non-bare Git worktree", exit_code=2
173
+ )
174
+ try:
175
+ discovered = Path(_decode_path(_without_one_line_ending(root.stdout))).resolve(strict=True)
176
+ except (OSError, RuntimeError):
177
+ raise ContextLoaderError(
178
+ "--repo must be the canonical root of a non-bare Git worktree", exit_code=2
179
+ ) from None
180
+ if require_canonical_root and discovered != canonical:
181
+ raise ContextLoaderError(
182
+ "--repo must be the canonical root of a non-bare Git worktree", exit_code=2
183
+ )
184
+ return RepositoryLocation(requested_path=canonical, canonical_root=discovered)
185
+
186
+
187
+ def validate_repository(raw_path: str | os.PathLike[str]) -> Path:
188
+ """Return the canonical root of an explicitly supplied Git worktree root."""
189
+ return discover_repository(raw_path, require_canonical_root=True).canonical_root
190
+
191
+
192
+ def _head_and_branch(repo: Path) -> tuple[str, str, str | None]:
193
+ symbolic = _run_git(repo, ("symbolic-ref", "--quiet", "--short", "HEAD"), check=False)
194
+ branch_name = (
195
+ _decode_path(_without_one_line_ending(symbolic.stdout))
196
+ if symbolic.returncode == 0
197
+ else None
198
+ )
199
+ resolved_head = _run_git(repo, ("rev-parse", "--verify", "HEAD^{commit}"), check=False)
200
+ if resolved_head.returncode != 0:
201
+ if branch_name is None:
202
+ raise ContextLoaderError("unable to read repository Git state")
203
+ return branch_name, "unborn", branch_name
204
+
205
+ head = _decode_text(_without_one_line_ending(resolved_head.stdout))
206
+ if _OID_PATTERN.fullmatch(head) is None:
207
+ raise ContextLoaderError("unable to read repository Git state")
208
+ return branch_name or "detached", head, branch_name
209
+
210
+
211
+ def _config_value(repo: Path, key: str) -> str | None:
212
+ result = _run_git(repo, ("config", "--get", key), check=False)
213
+ if result.returncode != 0:
214
+ return None
215
+ value = _decode_text(_without_one_line_ending(result.stdout))
216
+ return value or None
217
+
218
+
219
+ def _fallback_upstream(repo: Path, branch_name: str) -> str | None:
220
+ remote = _config_value(repo, f"branch.{branch_name}.remote")
221
+ merge = _config_value(repo, f"branch.{branch_name}.merge")
222
+ if remote is None or merge is None:
223
+ return None
224
+ merge_name = merge.removeprefix("refs/heads/")
225
+ if remote == ".":
226
+ return merge_name
227
+ return f"{remote}/{merge_name}"
228
+
229
+
230
+ def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
231
+ if branch_name is None:
232
+ return "not configured", "not available"
233
+
234
+ resolved = _run_git(
235
+ repo,
236
+ ("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"),
237
+ check=False,
238
+ )
239
+ if resolved.returncode != 0:
240
+ configured = _fallback_upstream(repo, branch_name)
241
+ return configured or "not configured", "not available"
242
+
243
+ upstream = _decode_text(_without_one_line_ending(resolved.stdout))
244
+ if not upstream:
245
+ raise ContextLoaderError("unable to read repository Git state")
246
+ divergence = _run_git(
247
+ repo,
248
+ ("rev-list", "--left-right", "--count", "HEAD...@{upstream}"),
249
+ check=False,
250
+ )
251
+ if divergence.returncode != 0:
252
+ return upstream, "not available"
253
+ counts = _without_one_line_ending(divergence.stdout).split()
254
+ if len(counts) != 2 or any(not count.isdigit() for count in counts):
255
+ return upstream, "not available"
256
+ return upstream, f"{counts[0].decode('ascii')} / {counts[1].decode('ascii')}"
257
+
258
+
259
+ def _working_tree_changes(repo: Path) -> tuple[WorkingTreeChange, ...]:
260
+ result = _run_git(
261
+ repo,
262
+ (
263
+ "status",
264
+ "--porcelain=v1",
265
+ "-z",
266
+ "--untracked-files=all",
267
+ "--no-renames",
268
+ "--ignore-submodules=none",
269
+ ),
270
+ )
271
+ if not result.stdout:
272
+ return ()
273
+ if not result.stdout.endswith(b"\0"):
274
+ raise ContextLoaderError("unable to parse repository Git state")
275
+
276
+ changes: list[WorkingTreeChange] = []
277
+ for record in result.stdout[:-1].split(b"\0"):
278
+ if len(record) < 4 or record[2:3] != b" ":
279
+ raise ContextLoaderError("unable to parse repository Git state")
280
+ try:
281
+ status = record[:2].decode("ascii")
282
+ except UnicodeDecodeError:
283
+ raise ContextLoaderError("unable to parse repository Git state") from None
284
+ path = record[3:]
285
+ if not path:
286
+ raise ContextLoaderError("unable to parse repository Git state")
287
+ changes.append(WorkingTreeChange(status, _decode_path(path), path))
288
+ return tuple(sorted(changes, key=lambda change: change.sort_key))
289
+
290
+
291
+ def _recent_commits(repo: Path, head: str) -> tuple[RecentCommit, ...]:
292
+ if head == "unborn":
293
+ return ()
294
+ result = _run_git(
295
+ repo,
296
+ (
297
+ "log",
298
+ f"--max-count={MAX_RECENT_COMMITS}",
299
+ "--no-decorate",
300
+ "--format=%H%x09%aI%x09%s",
301
+ "HEAD",
302
+ "--",
303
+ ),
304
+ )
305
+ commits: list[RecentCommit] = []
306
+ for line in result.stdout.splitlines():
307
+ fields = line.split(b"\t", 2)
308
+ if len(fields) != 3:
309
+ raise ContextLoaderError("unable to parse repository Git state")
310
+ object_id = _decode_text(fields[0])
311
+ if _OID_PATTERN.fullmatch(object_id) is None:
312
+ raise ContextLoaderError("unable to parse repository Git state")
313
+ commits.append(
314
+ RecentCommit(
315
+ object_id=object_id,
316
+ author_date=_decode_text(fields[1]),
317
+ subject=_decode_text(fields[2]),
318
+ )
319
+ )
320
+ return tuple(commits)
321
+
322
+
323
+ def collect_repository(
324
+ raw_path: str | os.PathLike[str], *, require_canonical_root: bool = True
325
+ ) -> tuple[RepositoryLocation, RepositoryState]:
326
+ """Discover one repository and collect only the Phase 01 Git fields."""
327
+ location = discover_repository(raw_path, require_canonical_root=require_canonical_root)
328
+ repository = location.canonical_root
329
+ branch, head, branch_name = _head_and_branch(repository)
330
+ upstream, ahead_behind = _upstream_state(repository, branch_name)
331
+ changes = _working_tree_changes(repository)
332
+ commits = _recent_commits(repository, head)
333
+ state = RepositoryState(
334
+ repository=repository,
335
+ branch=branch,
336
+ head=head,
337
+ upstream=upstream,
338
+ ahead_behind=ahead_behind,
339
+ changes=changes,
340
+ commits=commits,
341
+ )
342
+ return location, state
343
+
344
+
345
+ def collect_repository_state(raw_path: str | os.PathLike[str]) -> RepositoryState:
346
+ """Validate one repository root and collect only the Phase 01 Git fields."""
347
+ _location, state = collect_repository(raw_path)
348
+ return state