context-engineering-cli 2.6.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.
Files changed (55) hide show
  1. context_engineering/__init__.py +3 -0
  2. context_engineering/__main__.py +2 -0
  3. context_engineering/analysis/__init__.py +1 -0
  4. context_engineering/analysis/backfill.py +1064 -0
  5. context_engineering/analysis/context_check.py +253 -0
  6. context_engineering/analysis/context_layout.py +111 -0
  7. context_engineering/analysis/context_review.py +224 -0
  8. context_engineering/analysis/cross_cutting/__init__.py +6 -0
  9. context_engineering/analysis/cross_cutting/authors.py +57 -0
  10. context_engineering/analysis/cross_cutting/buckets.py +40 -0
  11. context_engineering/analysis/cross_cutting/co_change.py +47 -0
  12. context_engineering/analysis/cross_cutting/discover.py +75 -0
  13. context_engineering/analysis/cross_cutting/imports.py +61 -0
  14. context_engineering/analysis/cross_cutting/pair.py +118 -0
  15. context_engineering/analysis/impact.py +77 -0
  16. context_engineering/analysis/sessions.py +27 -0
  17. context_engineering/analysis/staleness.py +179 -0
  18. context_engineering/analysis/tier.py +91 -0
  19. context_engineering/checks/__init__.py +1 -0
  20. context_engineering/checks/antipatterns/__init__.py +5 -0
  21. context_engineering/checks/antipatterns/context.py +23 -0
  22. context_engineering/checks/antipatterns/density.py +72 -0
  23. context_engineering/checks/antipatterns/line_limits.py +52 -0
  24. context_engineering/checks/antipatterns/runner.py +137 -0
  25. context_engineering/checks/antipatterns/splitting.py +97 -0
  26. context_engineering/checks/antipatterns/volatile.py +38 -0
  27. context_engineering/checks/antipatterns/watermark.py +113 -0
  28. context_engineering/checks/contracts.py +456 -0
  29. context_engineering/checks/depth.py +82 -0
  30. context_engineering/checks/frontmatter.py +125 -0
  31. context_engineering/checks/references.py +325 -0
  32. context_engineering/checks/skill_structure.py +124 -0
  33. context_engineering/cli/__init__.py +3 -0
  34. context_engineering/cli/dispatch.py +90 -0
  35. context_engineering/cli/registry.py +33 -0
  36. context_engineering/cli/render.py +92 -0
  37. context_engineering/cli/subcommands.py +587 -0
  38. context_engineering/domain/__init__.py +0 -0
  39. context_engineering/domain/commit.py +19 -0
  40. context_engineering/domain/evidence.py +57 -0
  41. context_engineering/domain/finding.py +37 -0
  42. context_engineering/domain/result.py +59 -0
  43. context_engineering/infra/__init__.py +13 -0
  44. context_engineering/infra/filesystem.py +22 -0
  45. context_engineering/infra/git.py +153 -0
  46. context_engineering/infra/git_evidence.py +357 -0
  47. context_engineering/infra/git_tree.py +139 -0
  48. context_engineering/infra/markdown.py +58 -0
  49. context_engineering/infra/yaml_frontmatter.py +70 -0
  50. context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
  51. context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
  52. context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
  53. context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
  54. context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
  55. provenance.json +1 -0
@@ -0,0 +1,59 @@
1
+ """Result containers — LintResult (findings) and AnalysisResult (data + findings)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from .finding import Finding, Severity
9
+
10
+
11
+ @dataclass
12
+ class LintResult:
13
+ target: str
14
+ findings: list[Finding] = field(default_factory=list)
15
+
16
+ @property
17
+ def error_count(self) -> int:
18
+ return sum(1 for f in self.findings if f.severity == Severity.ERROR)
19
+
20
+ @property
21
+ def warning_count(self) -> int:
22
+ return sum(1 for f in self.findings if f.severity == Severity.WARNING)
23
+
24
+ @property
25
+ def info_count(self) -> int:
26
+ return sum(1 for f in self.findings if f.severity == Severity.INFO)
27
+
28
+ @property
29
+ def has_errors(self) -> bool:
30
+ return self.error_count > 0
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ return {
34
+ "target": self.target,
35
+ "findings": [f.to_dict() for f in self.findings],
36
+ "summary": {
37
+ "error": self.error_count,
38
+ "warning": self.warning_count,
39
+ "info": self.info_count,
40
+ },
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class AnalysisResult:
46
+ target: str
47
+ data: dict[str, Any] = field(default_factory=dict)
48
+ findings: list[Finding] = field(default_factory=list)
49
+
50
+ @property
51
+ def has_errors(self) -> bool:
52
+ return any(finding.severity == Severity.ERROR for finding in self.findings)
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "target": self.target,
57
+ "data": self.data,
58
+ "findings": [f.to_dict() for f in self.findings],
59
+ }
@@ -0,0 +1,13 @@
1
+ """Thin wrappers around the outside world — git, YAML, filesystem."""
2
+
3
+ from .filesystem import EXCLUDED_DIRS, read_text_safe
4
+ from .git import git_log, git_root
5
+ from .yaml_frontmatter import parse_frontmatter
6
+
7
+ __all__ = [
8
+ "EXCLUDED_DIRS",
9
+ "git_log",
10
+ "git_root",
11
+ "parse_frontmatter",
12
+ "read_text_safe",
13
+ ]
@@ -0,0 +1,22 @@
1
+ """Shared filesystem helpers — excluded-dir set + safe text read.
2
+
3
+ Each checker/analyzer does its own specialized discovery walk (docs-only,
4
+ AGENTS.md + docs pairs, git-tracked files), so we don't try to provide a
5
+ one-size-fits-all walker. Consumers check `EXCLUDED_DIRS` directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ EXCLUDED_DIRS: frozenset[str] = frozenset(
13
+ {".git", ".venv", "_archive", "archive", "node_modules", "__pycache__"}
14
+ )
15
+
16
+
17
+ def read_text_safe(path: Path) -> str | None:
18
+ """Read text or return None on OSError/UnicodeDecodeError/missing."""
19
+ try:
20
+ return path.read_text(encoding="utf-8")
21
+ except (OSError, UnicodeDecodeError):
22
+ return None
@@ -0,0 +1,153 @@
1
+ """One git wrapper. Replaces three separate git-log parsers across modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import subprocess
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+
10
+ from ..domain.commit import Commit
11
+
12
+ _COMMIT_SENTINEL = "\x1fCOMMIT\x1f"
13
+ _FIELD_SEP = "\x1f"
14
+
15
+
16
+ def resolve_commit(repo: Path, ref: str) -> tuple[str | None, str | None]:
17
+ """Resolve ``ref`` to a full commit SHA without allowing Git options."""
18
+ try:
19
+ result = subprocess.run(
20
+ ["git", "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"],
21
+ capture_output=True,
22
+ text=True,
23
+ cwd=repo,
24
+ )
25
+ except FileNotFoundError:
26
+ return None, "Git is not installed or is not on PATH"
27
+ if result.returncode != 0:
28
+ diagnostic = result.stderr.strip() or f"git rev-parse exited {result.returncode}"
29
+ return None, diagnostic
30
+ return result.stdout.strip(), None
31
+
32
+
33
+ def resolve_merge_base(repo: Path, base: str, head: str) -> tuple[str | None, str | None]:
34
+ """Resolve the unique merge base used by a three-dot diff."""
35
+ try:
36
+ result = subprocess.run(
37
+ ["git", "merge-base", "--", base, head],
38
+ capture_output=True,
39
+ text=True,
40
+ cwd=repo,
41
+ )
42
+ except FileNotFoundError:
43
+ return None, "Git is not installed or is not on PATH"
44
+ if result.returncode != 0:
45
+ diagnostic = result.stderr.strip() or f"git merge-base exited {result.returncode}"
46
+ return None, diagnostic
47
+ candidates = result.stdout.splitlines()
48
+ if len(candidates) != 1:
49
+ return None, "Git did not return one merge base"
50
+ return candidates[0], None
51
+
52
+
53
+ def git_root(path: Path) -> Path | None:
54
+ """Return repo root for `path`, or None if not inside a git repo."""
55
+ search = path if path.is_dir() else path.parent
56
+ while not search.exists() and search != search.parent:
57
+ search = search.parent
58
+ if not search.exists():
59
+ return None
60
+ try:
61
+ result = subprocess.run(
62
+ ["git", "rev-parse", "--show-toplevel"],
63
+ capture_output=True,
64
+ text=True,
65
+ cwd=search,
66
+ )
67
+ except FileNotFoundError:
68
+ return None
69
+ if result.returncode != 0:
70
+ return None
71
+ reported = Path(result.stdout.strip())
72
+ candidate = search
73
+ while True:
74
+ try:
75
+ if candidate.samefile(reported):
76
+ return candidate
77
+ except OSError:
78
+ pass
79
+ if candidate == candidate.parent:
80
+ break
81
+ candidate = candidate.parent
82
+ return reported
83
+
84
+
85
+ def git_log(
86
+ repo: Path,
87
+ *,
88
+ since_days: int | None = None,
89
+ since_ref: str | None = None,
90
+ paths: list[str] | None = None,
91
+ max_count: int | None = None,
92
+ ) -> Iterator[Commit]:
93
+ """Stream commits from `repo`, newest first.
94
+
95
+ - `since_days`: restrict to commits within the last N days.
96
+ - `since_ref`: restrict to `<ref>..HEAD` (commits since a specific SHA/ref).
97
+ - `paths`: restrict to commits touching any of these paths.
98
+ - `max_count`: cap the number of commits (for lookups like "most recent one").
99
+ """
100
+ root = git_root(repo)
101
+ if root is None:
102
+ return
103
+
104
+ pretty = f"--pretty=format:{_COMMIT_SENTINEL}%H{_FIELD_SEP}%an{_FIELD_SEP}%aI"
105
+ cmd = ["git", "log", "--name-only", pretty]
106
+ if max_count is not None:
107
+ cmd.append(f"-{max_count}")
108
+ if since_days is not None:
109
+ cmd.append(f"--since={since_days}.days.ago")
110
+ if since_ref:
111
+ cmd.append(f"{since_ref}..HEAD")
112
+ if paths:
113
+ cmd.append("--")
114
+ cmd.extend(paths)
115
+
116
+ try:
117
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=root)
118
+ except FileNotFoundError:
119
+ return
120
+ if result.returncode != 0:
121
+ return
122
+
123
+ yield from _parse(result.stdout)
124
+
125
+
126
+ def _parse(stdout: str) -> Iterator[Commit]:
127
+ sha = ""
128
+ author = ""
129
+ date = datetime.datetime.min.replace(tzinfo=datetime.UTC)
130
+ files: list[str] = []
131
+ have_header = False
132
+
133
+ for raw in stdout.splitlines():
134
+ if raw.startswith(_COMMIT_SENTINEL):
135
+ if have_header:
136
+ yield Commit(sha=sha, author=author, date=date, files=tuple(files))
137
+ header = raw[len(_COMMIT_SENTINEL) :]
138
+ parts = header.split(_FIELD_SEP)
139
+ if len(parts) < 3:
140
+ have_header = False
141
+ continue
142
+ sha, author, date_str = parts[0], parts[1], parts[2]
143
+ try:
144
+ date = datetime.datetime.fromisoformat(date_str)
145
+ except ValueError:
146
+ date = datetime.datetime.min.replace(tzinfo=datetime.UTC)
147
+ files = []
148
+ have_header = True
149
+ elif raw.strip() and have_header:
150
+ files.append(raw.strip())
151
+
152
+ if have_header:
153
+ yield Commit(sha=sha, author=author, date=date, files=tuple(files))
@@ -0,0 +1,357 @@
1
+ """Byte-safe Git history and diff evidence parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import threading
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import NotRequired, TypedDict
12
+
13
+ _RECORD = b"\x1e"
14
+ _FIELD = "\x1f"
15
+ _DEFAULT_MAX_BYTES = 256 * 1024 * 1024
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class EvidenceBudget:
20
+ max_records: int = 100_000
21
+ max_paths: int = 1_000_000
22
+ max_bytes: int = _DEFAULT_MAX_BYTES
23
+ max_subprocesses: int = 10_000
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class EvidenceCounts:
28
+ records: int
29
+ paths: int
30
+ bytes: int
31
+ subprocesses: int = 1
32
+
33
+ def to_dict(self) -> dict[str, int]:
34
+ return {
35
+ "records": self.records,
36
+ "paths": self.paths,
37
+ "bytes": self.bytes,
38
+ "subprocesses": self.subprocesses,
39
+ }
40
+
41
+
42
+ class CompletePathChange(TypedDict):
43
+ status: str
44
+ path: str
45
+ old_path: NotRequired[str]
46
+
47
+
48
+ class CompleteHistoryRecord(TypedDict):
49
+ commit: str
50
+ date: str
51
+ subject: str
52
+ parents: list[str]
53
+ changes: list[CompletePathChange]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class HistoryRecord:
58
+ commit: str
59
+ date: str
60
+ subject: str
61
+ paths: tuple[str, ...]
62
+
63
+ def to_dict(self) -> dict[str, object]:
64
+ return {
65
+ "commit": self.commit,
66
+ "date": self.date,
67
+ "subject": self.subject,
68
+ "paths": list(self.paths),
69
+ }
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PathChange:
74
+ status: str
75
+ path: str
76
+ old_path: str | None = None
77
+
78
+
79
+ def communicate_bounded(
80
+ process: subprocess.Popen[bytes],
81
+ *,
82
+ max_bytes: int,
83
+ timeout: float = 120,
84
+ ) -> tuple[bytes, bytes, str | None]:
85
+ """Drain stdout and stderr concurrently under one combined byte ceiling."""
86
+ assert process.stdout is not None and process.stderr is not None
87
+ stdout = bytearray()
88
+ stderr = bytearray()
89
+ lock = threading.Lock()
90
+ exceeded = threading.Event()
91
+ observed = 0
92
+
93
+ def drain(stream, sink: bytearray) -> None:
94
+ nonlocal observed
95
+ while chunk := stream.read(64 * 1024):
96
+ with lock:
97
+ observed += len(chunk)
98
+ if len(stdout) + len(stderr) <= max_bytes:
99
+ remaining = max_bytes + 1 - len(stdout) - len(stderr)
100
+ sink.extend(chunk[:remaining])
101
+ if observed > max_bytes and not exceeded.is_set():
102
+ exceeded.set()
103
+ process.kill()
104
+
105
+ threads = [
106
+ threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
107
+ threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
108
+ ]
109
+ for thread in threads:
110
+ thread.start()
111
+ error: str | None = None
112
+ try:
113
+ process.wait(timeout=timeout)
114
+ except subprocess.TimeoutExpired:
115
+ process.kill()
116
+ error = "Git subprocess timed out"
117
+ for thread in threads:
118
+ thread.join(timeout=5)
119
+ if any(thread.is_alive() for thread in threads):
120
+ process.kill()
121
+ error = error or "Git subprocess streams did not close"
122
+ if exceeded.is_set():
123
+ error = "Git subprocess output exceeds the configured byte ceiling"
124
+ return bytes(stdout), bytes(stderr), error
125
+
126
+
127
+ def parse_history_output(stdout: bytes) -> tuple[list[HistoryRecord], str | None]:
128
+ """Parse NUL-delimited history without decoding repository paths as UTF-8."""
129
+ records: list[HistoryRecord] = []
130
+ header: tuple[str, str, str] | None = None
131
+ paths: list[str] = []
132
+ first_path = False
133
+ for raw_token in stdout.split(b"\0"):
134
+ token = raw_token
135
+ structural = token[1:] if token.startswith(b"\n" + _RECORD) else token
136
+ if structural.startswith(_RECORD):
137
+ if header is not None and paths:
138
+ records.append(HistoryRecord(*header, tuple(sorted(paths))))
139
+ fields = os.fsdecode(structural[len(_RECORD) :]).split(_FIELD, 2)
140
+ if len(fields) != 3:
141
+ return [], "git log returned malformed commit metadata"
142
+ header = (fields[0], fields[1][:10], fields[2])
143
+ paths = []
144
+ first_path = True
145
+ continue
146
+ if not token:
147
+ continue
148
+ if header is None:
149
+ return [], "git log returned a path before commit metadata"
150
+ if first_path and token.startswith(b"\n"):
151
+ token = token[1:]
152
+ first_path = False
153
+ if token:
154
+ paths.append(os.fsdecode(token))
155
+ if header is not None and paths:
156
+ records.append(HistoryRecord(*header, tuple(sorted(paths))))
157
+ return records, None
158
+
159
+
160
+ def read_history(
161
+ repo: Path,
162
+ *,
163
+ cutoff: str,
164
+ relative_path: str,
165
+ max_count: int,
166
+ ) -> tuple[list[HistoryRecord], str | None]:
167
+ command = [
168
+ "git",
169
+ "log",
170
+ f"--max-count={max_count}",
171
+ "--format=%x1e%H%x1f%aI%x1f%s",
172
+ "--name-only",
173
+ "-z",
174
+ "--end-of-options",
175
+ cutoff,
176
+ "--",
177
+ ]
178
+ if relative_path != ".":
179
+ command.append(relative_path)
180
+ try:
181
+ result = subprocess.run(command, cwd=repo, capture_output=True)
182
+ except FileNotFoundError:
183
+ return [], "Git is not installed or is not on PATH"
184
+ if result.returncode != 0:
185
+ diagnostic = os.fsdecode(result.stderr).strip()
186
+ return [], diagnostic or f"git log exited {result.returncode}"
187
+ return parse_history_output(result.stdout)
188
+
189
+
190
+ def parse_diff_output(stdout: bytes) -> tuple[list[PathChange], str | None]:
191
+ """Parse `git diff --name-status -z` output into exact path changes."""
192
+ tokens = stdout.split(b"\0")
193
+ if tokens and tokens[-1] == b"":
194
+ tokens.pop()
195
+ changes: list[PathChange] = []
196
+ index = 0
197
+ while index < len(tokens):
198
+ raw_status = os.fsdecode(tokens[index])
199
+ index += 1
200
+ if not raw_status:
201
+ return [], "git diff returned an empty status field"
202
+ code = raw_status[:1]
203
+ path_count = 2 if code in {"R", "C"} else 1
204
+ if index + path_count > len(tokens):
205
+ return [], "git diff returned an incomplete NUL-delimited path record"
206
+ paths = [os.fsdecode(tokens[index + offset]) for offset in range(path_count)]
207
+ index += path_count
208
+ changes.append(
209
+ PathChange(
210
+ status=raw_status,
211
+ path=paths[-1],
212
+ old_path=paths[0] if path_count == 2 else None,
213
+ )
214
+ )
215
+ return changes, None
216
+
217
+
218
+ def read_diff(
219
+ repo: Path,
220
+ *,
221
+ base: str,
222
+ head: str,
223
+ ) -> tuple[list[PathChange], str | None]:
224
+ command = ["git", "diff", "--name-status", "-z", "--find-renames", f"{base}...{head}", "--"]
225
+ try:
226
+ result = subprocess.run(command, cwd=repo, capture_output=True)
227
+ except FileNotFoundError:
228
+ return [], "Git is not installed or is not on PATH"
229
+ if result.returncode != 0:
230
+ diagnostic = os.fsdecode(result.stderr).strip()
231
+ return [], diagnostic or f"git diff exited {result.returncode}"
232
+ return parse_diff_output(result.stdout)
233
+
234
+
235
+ def parse_complete_history_output(
236
+ stdout: bytes,
237
+ *,
238
+ budget: EvidenceBudget,
239
+ ) -> tuple[list[CompleteHistoryRecord], EvidenceCounts, str | None]:
240
+ """Parse complete name-status history with explicit resource accounting."""
241
+ records: list[CompleteHistoryRecord] = []
242
+ current: CompleteHistoryRecord | None = None
243
+ path_count = 0
244
+ tokens = stdout.split(b"\0")
245
+ index = 0
246
+ while index < len(tokens):
247
+ token = os.fsdecode(tokens[index])
248
+ structural = token.lstrip("\r\n")
249
+ if structural.startswith("\x1e"):
250
+ if current is not None:
251
+ records.append(current)
252
+ if len(records) > budget.max_records:
253
+ counts = EvidenceCounts(len(records), path_count, len(stdout))
254
+ return [], counts, "history exceeds the configured record ceiling"
255
+ fields = structural[1:].split(_FIELD, 3)
256
+ current = None
257
+ if len(fields) != 4:
258
+ return (
259
+ [],
260
+ EvidenceCounts(len(records), path_count, len(stdout)),
261
+ ("git log returned malformed commit metadata"),
262
+ )
263
+ current = {
264
+ "commit": fields[0],
265
+ "date": fields[1][:10],
266
+ "subject": fields[2],
267
+ "parents": fields[3].split() if fields[3] else [],
268
+ "changes": [],
269
+ }
270
+ index += 1
271
+ continue
272
+ status = structural
273
+ rename_or_copy = re.fullmatch(r"[RC][0-9]+", status)
274
+ tuple_paths = 2 if rename_or_copy else 1
275
+ if current is None or not re.fullmatch(r"(?:[RC][0-9]+|[ACDMRTUXB]+)", status):
276
+ index += 1
277
+ continue
278
+ if index + tuple_paths >= len(tokens):
279
+ return (
280
+ [],
281
+ EvidenceCounts(len(records), path_count, len(stdout)),
282
+ ("git log returned an incomplete NUL-delimited path record"),
283
+ )
284
+ paths = [os.fsdecode(tokens[index + offset]) for offset in range(1, tuple_paths + 1)]
285
+ path_count += tuple_paths
286
+ if path_count > budget.max_paths:
287
+ counts = EvidenceCounts(len(records), path_count, len(stdout))
288
+ return [], counts, "history exceeds the configured path ceiling"
289
+ current["changes"].append(
290
+ {"status": status, "path": paths[0]}
291
+ if tuple_paths == 1
292
+ else {"status": status, "old_path": paths[0], "path": paths[1]}
293
+ )
294
+ index += tuple_paths + 1
295
+ if current is not None:
296
+ records.append(current)
297
+ counts = EvidenceCounts(len(records), path_count, len(stdout))
298
+ if counts.records > budget.max_records:
299
+ return [], counts, "history exceeds the configured record ceiling"
300
+ return records, counts, None
301
+
302
+
303
+ def read_complete_history(
304
+ repo: Path,
305
+ *,
306
+ cutoff: str,
307
+ budget: EvidenceBudget | None = None,
308
+ ) -> tuple[list[CompleteHistoryRecord], EvidenceCounts, str | None]:
309
+ """Read complete history while stopping before unbounded stdout allocation."""
310
+ active_budget = budget or EvidenceBudget()
311
+ command = [
312
+ "git",
313
+ "log",
314
+ "--reverse",
315
+ "--topo-order",
316
+ "--full-history",
317
+ "--diff-merges=combined",
318
+ "--find-renames=50%",
319
+ f"--format=\x1e%H{_FIELD}%aI{_FIELD}%s{_FIELD}%P",
320
+ "--name-status",
321
+ "-z",
322
+ "--end-of-options",
323
+ cutoff,
324
+ ]
325
+ try:
326
+ process = subprocess.Popen(
327
+ command,
328
+ cwd=repo,
329
+ stdout=subprocess.PIPE,
330
+ stderr=subprocess.PIPE,
331
+ )
332
+ except OSError as exc:
333
+ return [], EvidenceCounts(0, 0, 0), f"could not run Git: {exc}"
334
+ stdout, stderr, transport_error = communicate_bounded(
335
+ process,
336
+ max_bytes=active_budget.max_bytes,
337
+ )
338
+ total_bytes = len(stdout) + len(stderr)
339
+ if transport_error is not None:
340
+ return (
341
+ [],
342
+ EvidenceCounts(0, 0, total_bytes),
343
+ (
344
+ "history exceeds the configured byte ceiling"
345
+ if "ceiling" in transport_error
346
+ else transport_error
347
+ ),
348
+ )
349
+ if process.returncode != 0:
350
+ diagnostic = os.fsdecode(stderr).strip()
351
+ return (
352
+ [],
353
+ EvidenceCounts(0, 0, total_bytes),
354
+ (diagnostic or f"git log exited {process.returncode}"),
355
+ )
356
+ records, counts, error = parse_complete_history_output(stdout, budget=active_budget)
357
+ return records, EvidenceCounts(counts.records, counts.paths, total_bytes), error