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/__init__.py +3 -0
- context_loader/application.py +197 -0
- context_loader/cli.py +75 -0
- context_loader/collect.py +993 -0
- context_loader/git.py +348 -0
- context_loader/render.py +377 -0
- context_loader-0.1.8.dist-info/METADATA +555 -0
- context_loader-0.1.8.dist-info/RECORD +10 -0
- context_loader-0.1.8.dist-info/WHEEL +4 -0
- context_loader-0.1.8.dist-info/entry_points.txt +3 -0
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
|