@homericintelligence/athena-opencode 0.4.4
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.
- package/LICENSE +29 -0
- package/NOTICE +22 -0
- package/README.md +45 -0
- package/package.json +29 -0
- package/plugin.js +54 -0
- package/skills/THIRD_PARTY_LICENSES.md +50 -0
- package/skills/_cli.py +152 -0
- package/skills/advise/SKILL.md +62 -0
- package/skills/advise/scripts/list_retrievable_skills.py +49 -0
- package/skills/brainstorm/SKILL.md +110 -0
- package/skills/change-review/SKILL.md +68 -0
- package/skills/change-review/references/scope-resolution.md +52 -0
- package/skills/change-review/scripts/resolve_scope.py +1219 -0
- package/skills/finalize-plan/SKILL.md +129 -0
- package/skills/git-worktrees/SKILL.md +113 -0
- package/skills/git-worktrees/scripts/prepare_worktree.py +153 -0
- package/skills/issue-review/SKILL.md +67 -0
- package/skills/learn/SKILL.md +208 -0
- package/skills/myrmidon-swarm/SKILL.md +93 -0
- package/skills/plan-issue/SKILL.md +70 -0
- package/skills/pr-review/SKILL.md +114 -0
- package/skills/pr-review/references/criteria.md +26 -0
- package/skills/pr-review/references/delivery.md +135 -0
- package/skills/pr-review/references/evidence.md +233 -0
- package/skills/pr-review/references/prevalidated.md +155 -0
- package/skills/pr-review/scripts/collect_evidence.py +1478 -0
- package/skills/pr-review/scripts/diff_context.py +74 -0
- package/skills/pr-review/scripts/materialize_snapshot.py +731 -0
- package/skills/pr-review/scripts/pr_identity.py +80 -0
- package/skills/pr-review/scripts/resolve_pr.py +258 -0
- package/skills/repo-review/SKILL.md +119 -0
- package/skills/systematic-debugging/SKILL.md +199 -0
- package/skills/systematic-debugging/scripts/repository_evidence.py +77 -0
- package/skills/test-driven-development/SKILL.md +75 -0
- package/skills/tidy/SKILL.md +71 -0
- package/skills/tidy/scripts/run_tidy.py +43 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve a change-review scope without changing repository or Git state."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from hashlib import sha256
|
|
16
|
+
from operator import index
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Protocol, cast
|
|
19
|
+
|
|
20
|
+
if __package__ in {None, ""}:
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
22
|
+
|
|
23
|
+
from skills._cli import (
|
|
24
|
+
argument_parser,
|
|
25
|
+
git_read_arguments,
|
|
26
|
+
git_read_environment,
|
|
27
|
+
run_command,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
READ_CHUNK_SIZE = 1024 * 1024
|
|
31
|
+
ERROR_OUTPUT_LIMIT = 16 * 1024
|
|
32
|
+
MAX_METADATA_RECORD_BYTES = 128 * 1024
|
|
33
|
+
MAX_WORKTREE_CANDIDATES = 50_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def git_bytes(*arguments: str, repository_root: Path | None = None) -> bytes:
|
|
37
|
+
"""Run Git and return its raw stdout or raise a concise error."""
|
|
38
|
+
command = git_command(arguments, repository_root)
|
|
39
|
+
result = run_command(
|
|
40
|
+
command,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
env=git_read_environment(),
|
|
43
|
+
text=False,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
stdout = cast(bytes, result.stdout)
|
|
47
|
+
stderr = cast(bytes, result.stderr)
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
message = stderr.decode("utf-8", errors="replace").strip()
|
|
50
|
+
raise RuntimeError(message or f"git {' '.join(arguments)} failed")
|
|
51
|
+
return stdout
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def git_text(*arguments: str, repository_root: Path | None = None) -> str:
|
|
55
|
+
"""Run Git and decode a single-line textual response."""
|
|
56
|
+
output = git_bytes(*arguments, repository_root=repository_root).decode(
|
|
57
|
+
"utf-8", errors="surrogateescape"
|
|
58
|
+
)
|
|
59
|
+
return output.removesuffix("\n")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def path_list(document: bytes) -> list[str]:
|
|
63
|
+
"""Return sorted Git NUL-delimited paths without lossy shell parsing."""
|
|
64
|
+
return sorted(os.fsdecode(path) for path in document.split(b"\0") if path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class PathEntry:
|
|
69
|
+
"""One no-follow worktree or immutable Git-object manifest entry."""
|
|
70
|
+
|
|
71
|
+
path: str
|
|
72
|
+
kind: str
|
|
73
|
+
target: str | None = None
|
|
74
|
+
object_id: str | None = None
|
|
75
|
+
mode: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ContentFingerprint:
|
|
80
|
+
"""Bounded identity for an arbitrarily large byte stream."""
|
|
81
|
+
|
|
82
|
+
length: int
|
|
83
|
+
digest: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ScopeCapture:
|
|
88
|
+
"""One complete observed scope capture used to detect worktree races."""
|
|
89
|
+
|
|
90
|
+
paths: tuple[str, ...]
|
|
91
|
+
tracked_paths: tuple[str, ...]
|
|
92
|
+
untracked_paths: tuple[str, ...]
|
|
93
|
+
path_entries: tuple[PathEntry, ...]
|
|
94
|
+
tracked_diff: ContentFingerprint
|
|
95
|
+
scope_digest: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def content_fingerprint(chunks: Iterable[bytes]) -> ContentFingerprint:
|
|
99
|
+
"""Return a bounded SHA-256 identity for streamed bytes."""
|
|
100
|
+
digest = sha256()
|
|
101
|
+
length = 0
|
|
102
|
+
for chunk in chunks:
|
|
103
|
+
digest.update(chunk)
|
|
104
|
+
length += len(chunk)
|
|
105
|
+
return ContentFingerprint(length=length, digest=digest.hexdigest())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def git_command(arguments: Sequence[str], repository_root: Path | None) -> list[str]:
|
|
109
|
+
"""Build one Git command without shell interpolation."""
|
|
110
|
+
command = [
|
|
111
|
+
"git",
|
|
112
|
+
"-c",
|
|
113
|
+
"core.fsmonitor=false",
|
|
114
|
+
*git_read_arguments(),
|
|
115
|
+
]
|
|
116
|
+
if repository_root is not None:
|
|
117
|
+
command.extend(("-C", os.fspath(repository_root)))
|
|
118
|
+
command.extend(arguments)
|
|
119
|
+
return command
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def git_stream_fingerprint(
|
|
123
|
+
*arguments: str, repository_root: Path | None = None
|
|
124
|
+
) -> ContentFingerprint:
|
|
125
|
+
"""Fingerprint Git output without retaining a complete diff in memory."""
|
|
126
|
+
command = git_command(arguments, repository_root)
|
|
127
|
+
try:
|
|
128
|
+
with tempfile.TemporaryFile() as error_output:
|
|
129
|
+
try:
|
|
130
|
+
process = subprocess.Popen(
|
|
131
|
+
command,
|
|
132
|
+
env=git_read_environment(),
|
|
133
|
+
stdout=subprocess.PIPE,
|
|
134
|
+
stderr=error_output,
|
|
135
|
+
)
|
|
136
|
+
except FileNotFoundError as error:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"required command unavailable: {error.filename or command[0]}"
|
|
139
|
+
) from error
|
|
140
|
+
try:
|
|
141
|
+
stdout = process.stdout
|
|
142
|
+
if stdout is None:
|
|
143
|
+
raise RuntimeError("Git did not provide stdout for scope capture")
|
|
144
|
+
try:
|
|
145
|
+
fingerprint = content_fingerprint(
|
|
146
|
+
iter(lambda: stdout.read(READ_CHUNK_SIZE), b"")
|
|
147
|
+
)
|
|
148
|
+
finally:
|
|
149
|
+
stdout.close()
|
|
150
|
+
return_code = process.wait()
|
|
151
|
+
except BaseException:
|
|
152
|
+
if process.poll() is None:
|
|
153
|
+
process.kill()
|
|
154
|
+
process.wait()
|
|
155
|
+
raise
|
|
156
|
+
if return_code != 0:
|
|
157
|
+
error_output.seek(0)
|
|
158
|
+
message = (
|
|
159
|
+
error_output.read(ERROR_OUTPUT_LIMIT)
|
|
160
|
+
.decode("utf-8", errors="replace")
|
|
161
|
+
.strip()
|
|
162
|
+
)
|
|
163
|
+
raise RuntimeError(message or f"git {' '.join(arguments)} failed")
|
|
164
|
+
return fingerprint
|
|
165
|
+
except OSError as error:
|
|
166
|
+
raise RuntimeError(f"cannot stream git output: {error}") from error
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def consume_git_nul_records(
|
|
170
|
+
arguments: Sequence[str],
|
|
171
|
+
repository_root: Path,
|
|
172
|
+
consume: Callable[[bytes], None],
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Pass Git NUL records to a consumer without buffering command output."""
|
|
175
|
+
command = git_command(arguments, repository_root)
|
|
176
|
+
try:
|
|
177
|
+
with tempfile.TemporaryFile() as error_output:
|
|
178
|
+
try:
|
|
179
|
+
process = subprocess.Popen(
|
|
180
|
+
command,
|
|
181
|
+
env=git_read_environment(),
|
|
182
|
+
stdout=subprocess.PIPE,
|
|
183
|
+
stderr=error_output,
|
|
184
|
+
)
|
|
185
|
+
except FileNotFoundError as error:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"required command unavailable: {error.filename or command[0]}"
|
|
188
|
+
) from error
|
|
189
|
+
stdout = process.stdout
|
|
190
|
+
if stdout is None:
|
|
191
|
+
process.kill()
|
|
192
|
+
process.wait()
|
|
193
|
+
raise RuntimeError("Git did not provide stdout for scope capture")
|
|
194
|
+
pending = b""
|
|
195
|
+
try:
|
|
196
|
+
while chunk := stdout.read(READ_CHUNK_SIZE):
|
|
197
|
+
pending += chunk
|
|
198
|
+
records = pending.split(b"\0")
|
|
199
|
+
pending = records.pop()
|
|
200
|
+
if len(pending) > MAX_METADATA_RECORD_BYTES:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
"Git metadata record exceeds the safe scope limit"
|
|
203
|
+
)
|
|
204
|
+
for record in records:
|
|
205
|
+
if record:
|
|
206
|
+
if len(record) > MAX_METADATA_RECORD_BYTES:
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
"Git metadata record exceeds the safe scope limit"
|
|
209
|
+
)
|
|
210
|
+
consume(record)
|
|
211
|
+
if pending:
|
|
212
|
+
raise RuntimeError("unterminated Git metadata record")
|
|
213
|
+
return_code = process.wait()
|
|
214
|
+
except BaseException:
|
|
215
|
+
stdout.close()
|
|
216
|
+
if process.poll() is None:
|
|
217
|
+
process.kill()
|
|
218
|
+
process.wait()
|
|
219
|
+
raise
|
|
220
|
+
stdout.close()
|
|
221
|
+
if return_code != 0:
|
|
222
|
+
error_output.seek(0)
|
|
223
|
+
message = (
|
|
224
|
+
error_output.read(ERROR_OUTPUT_LIMIT)
|
|
225
|
+
.decode("utf-8", errors="replace")
|
|
226
|
+
.strip()
|
|
227
|
+
)
|
|
228
|
+
raise RuntimeError(message or f"git {' '.join(arguments)} failed")
|
|
229
|
+
except OSError as error:
|
|
230
|
+
raise RuntimeError(f"cannot stream git metadata: {error}") from error
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def pathspec_arguments(arguments: list[str], paths: Sequence[str]) -> list[str]:
|
|
234
|
+
"""Append repository-rooted literal pathspecs without pathspec injection."""
|
|
235
|
+
literal_paths = [] if "." in paths else [f":(top,literal){path}" for path in paths]
|
|
236
|
+
return [*arguments, "--", *literal_paths]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def normalized_paths(repository_root: Path, paths: Sequence[str]) -> list[str]:
|
|
240
|
+
"""Keep lexical filters inside the repository without following symlinks."""
|
|
241
|
+
root = Path(os.path.abspath(os.fspath(repository_root)))
|
|
242
|
+
normalized: list[str] = []
|
|
243
|
+
for raw_path in paths:
|
|
244
|
+
candidate = Path(raw_path)
|
|
245
|
+
absolute_candidate = (
|
|
246
|
+
candidate if candidate.is_absolute() else repository_root / candidate
|
|
247
|
+
)
|
|
248
|
+
resolved = Path(os.path.normpath(os.fspath(absolute_candidate)))
|
|
249
|
+
try:
|
|
250
|
+
relative = resolved.relative_to(root)
|
|
251
|
+
except ValueError as error:
|
|
252
|
+
raise RuntimeError(f"path outside repository: {raw_path!r}") from error
|
|
253
|
+
normalized.append(relative.as_posix())
|
|
254
|
+
return sorted(set(normalized))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def verified_commit(reference: str, repository_root: Path | None = None) -> str:
|
|
258
|
+
"""Resolve one non-option Git reference to an immutable commit OID."""
|
|
259
|
+
if not reference or reference.startswith("-"):
|
|
260
|
+
raise RuntimeError(f"invalid Git reference: {reference!r}")
|
|
261
|
+
return git_text(
|
|
262
|
+
"rev-parse",
|
|
263
|
+
"--verify",
|
|
264
|
+
f"{reference}^{{commit}}",
|
|
265
|
+
repository_root=repository_root,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def range_revisions(value: str, repository_root: Path) -> tuple[str, str]:
|
|
270
|
+
"""Resolve the required BASE..HEAD notation to immutable commit OIDs."""
|
|
271
|
+
if value.count("..") != 1:
|
|
272
|
+
raise RuntimeError("range must use exactly one BASE..HEAD separator")
|
|
273
|
+
base_reference, head_reference = value.split("..", maxsplit=1)
|
|
274
|
+
return (
|
|
275
|
+
verified_commit(base_reference, repository_root),
|
|
276
|
+
verified_commit(head_reference, repository_root),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def tracked_paths(
|
|
281
|
+
scope: str,
|
|
282
|
+
base: str,
|
|
283
|
+
head: str,
|
|
284
|
+
paths: Sequence[str],
|
|
285
|
+
repository_root: Path,
|
|
286
|
+
) -> list[str]:
|
|
287
|
+
"""Return the tracked paths selected by the requested scope."""
|
|
288
|
+
if scope == "worktree":
|
|
289
|
+
return list(worktree_tracked_capture(head, paths, repository_root).paths)
|
|
290
|
+
elif scope == "staged":
|
|
291
|
+
arguments = [
|
|
292
|
+
"-c",
|
|
293
|
+
"diff.autoRefreshIndex=false",
|
|
294
|
+
"diff",
|
|
295
|
+
"--cached",
|
|
296
|
+
"--no-ext-diff",
|
|
297
|
+
"--no-textconv",
|
|
298
|
+
"--ignore-submodules=none",
|
|
299
|
+
"--name-only",
|
|
300
|
+
"-z",
|
|
301
|
+
"--no-renames",
|
|
302
|
+
head,
|
|
303
|
+
]
|
|
304
|
+
else:
|
|
305
|
+
arguments = [
|
|
306
|
+
"-c",
|
|
307
|
+
"diff.autoRefreshIndex=false",
|
|
308
|
+
"diff",
|
|
309
|
+
"--no-ext-diff",
|
|
310
|
+
"--no-textconv",
|
|
311
|
+
"--ignore-submodules=none",
|
|
312
|
+
"--name-only",
|
|
313
|
+
"-z",
|
|
314
|
+
"--no-renames",
|
|
315
|
+
f"{base}..{head}",
|
|
316
|
+
]
|
|
317
|
+
return path_list(
|
|
318
|
+
git_bytes(
|
|
319
|
+
*pathspec_arguments(arguments, paths), repository_root=repository_root
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def tracked_diff(
|
|
325
|
+
scope: str,
|
|
326
|
+
base: str,
|
|
327
|
+
head: str,
|
|
328
|
+
paths: Sequence[str],
|
|
329
|
+
repository_root: Path,
|
|
330
|
+
) -> ContentFingerprint:
|
|
331
|
+
"""Fingerprint the selected tracked change without buffering its full diff."""
|
|
332
|
+
if scope == "worktree":
|
|
333
|
+
return worktree_tracked_capture(head, paths, repository_root).fingerprint
|
|
334
|
+
elif scope == "staged":
|
|
335
|
+
arguments = [
|
|
336
|
+
"-c",
|
|
337
|
+
"diff.autoRefreshIndex=false",
|
|
338
|
+
"diff",
|
|
339
|
+
"--cached",
|
|
340
|
+
"--binary",
|
|
341
|
+
"--no-ext-diff",
|
|
342
|
+
"--no-textconv",
|
|
343
|
+
"--ignore-submodules=none",
|
|
344
|
+
"--no-renames",
|
|
345
|
+
head,
|
|
346
|
+
]
|
|
347
|
+
else:
|
|
348
|
+
arguments = [
|
|
349
|
+
"-c",
|
|
350
|
+
"diff.autoRefreshIndex=false",
|
|
351
|
+
"diff",
|
|
352
|
+
"--binary",
|
|
353
|
+
"--no-ext-diff",
|
|
354
|
+
"--no-textconv",
|
|
355
|
+
"--ignore-submodules=none",
|
|
356
|
+
"--no-renames",
|
|
357
|
+
f"{base}..{head}",
|
|
358
|
+
]
|
|
359
|
+
return git_stream_fingerprint(
|
|
360
|
+
*pathspec_arguments(arguments, paths), repository_root=repository_root
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def untracked_paths(paths: Sequence[str], repository_root: Path) -> list[str]:
|
|
365
|
+
"""Return bounded non-ignored untracked paths selected by worktree scope."""
|
|
366
|
+
selected: list[str] = []
|
|
367
|
+
|
|
368
|
+
def consume(record: bytes) -> None:
|
|
369
|
+
if len(selected) >= MAX_WORKTREE_CANDIDATES:
|
|
370
|
+
raise RuntimeError(
|
|
371
|
+
"untracked path limit "
|
|
372
|
+
f"({MAX_WORKTREE_CANDIDATES}) reached; rerun with narrower PATH arguments"
|
|
373
|
+
)
|
|
374
|
+
selected.append(os.fsdecode(record))
|
|
375
|
+
|
|
376
|
+
consume_git_nul_records(
|
|
377
|
+
pathspec_arguments(["ls-files", "--others", "--exclude-standard", "-z"], paths),
|
|
378
|
+
repository_root,
|
|
379
|
+
consume,
|
|
380
|
+
)
|
|
381
|
+
return sorted(selected)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class Digest(Protocol):
|
|
385
|
+
"""Minimal hashlib protocol used by the canonical scope digest."""
|
|
386
|
+
|
|
387
|
+
def update(self, data: bytes) -> None:
|
|
388
|
+
"""Add bytes to the digest state."""
|
|
389
|
+
|
|
390
|
+
def hexdigest(self) -> str:
|
|
391
|
+
"""Return the final hexadecimal digest."""
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def add_digest_part(digest: Digest, label: bytes, value: bytes) -> None:
|
|
395
|
+
"""Frame one digest part so inputs cannot collide by concatenation."""
|
|
396
|
+
digest.update(label)
|
|
397
|
+
digest.update(b"\0")
|
|
398
|
+
digest.update(str(len(value)).encode("ascii"))
|
|
399
|
+
digest.update(b"\0")
|
|
400
|
+
digest.update(value)
|
|
401
|
+
digest.update(b"\0")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def path_components(relative_path: str) -> tuple[str, ...]:
|
|
405
|
+
"""Return a verified repository-relative path split into lexical components."""
|
|
406
|
+
components = Path(relative_path).parts
|
|
407
|
+
if not components or any(component in {".", ".."} for component in components):
|
|
408
|
+
raise RuntimeError(f"invalid repository path: {relative_path!r}")
|
|
409
|
+
return components
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def close_descriptor_quietly(descriptor: int) -> None:
|
|
413
|
+
"""Release a descriptor during error handling without masking its cause."""
|
|
414
|
+
try:
|
|
415
|
+
os.close(descriptor)
|
|
416
|
+
except OSError:
|
|
417
|
+
# Cleanup must not mask the exception that triggered this path.
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def nofollow_parent_descriptor(
|
|
422
|
+
repository_root: Path, relative_path: str
|
|
423
|
+
) -> tuple[int, str]:
|
|
424
|
+
"""Open a path's parent without following any repository symlink."""
|
|
425
|
+
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
|
|
426
|
+
raise RuntimeError(
|
|
427
|
+
"host cannot inspect repository paths without following links"
|
|
428
|
+
)
|
|
429
|
+
components = path_components(relative_path)
|
|
430
|
+
descriptor: int | None = os.open(
|
|
431
|
+
repository_root,
|
|
432
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
433
|
+
)
|
|
434
|
+
try:
|
|
435
|
+
for component in components[:-1]:
|
|
436
|
+
assert descriptor is not None
|
|
437
|
+
try:
|
|
438
|
+
child_descriptor = os.open(
|
|
439
|
+
component,
|
|
440
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
441
|
+
dir_fd=descriptor,
|
|
442
|
+
)
|
|
443
|
+
except (NotImplementedError, TypeError) as error:
|
|
444
|
+
raise RuntimeError(
|
|
445
|
+
"host cannot inspect repository paths without following links"
|
|
446
|
+
) from error
|
|
447
|
+
try:
|
|
448
|
+
os.close(descriptor)
|
|
449
|
+
except OSError:
|
|
450
|
+
# `close()` leaves descriptor state unspecified on error; do not
|
|
451
|
+
# retry the parent descriptor, but never leak the opened child.
|
|
452
|
+
descriptor = None
|
|
453
|
+
close_descriptor_quietly(child_descriptor)
|
|
454
|
+
raise
|
|
455
|
+
descriptor = child_descriptor
|
|
456
|
+
except (OSError, RuntimeError):
|
|
457
|
+
if descriptor is not None:
|
|
458
|
+
close_descriptor_quietly(descriptor)
|
|
459
|
+
raise
|
|
460
|
+
assert descriptor is not None
|
|
461
|
+
return descriptor, components[-1]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def worktree_path_entry(repository_root: Path, relative_path: str) -> PathEntry:
|
|
465
|
+
"""Describe a path without following repository or target symlinks."""
|
|
466
|
+
try:
|
|
467
|
+
parent_descriptor, filename = nofollow_parent_descriptor(
|
|
468
|
+
repository_root, relative_path
|
|
469
|
+
)
|
|
470
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
471
|
+
return PathEntry(relative_path, "absent")
|
|
472
|
+
try:
|
|
473
|
+
try:
|
|
474
|
+
mode = os.lstat(filename, dir_fd=parent_descriptor).st_mode
|
|
475
|
+
except (NotImplementedError, TypeError) as error:
|
|
476
|
+
raise RuntimeError(
|
|
477
|
+
"host cannot inspect repository paths without following links"
|
|
478
|
+
) from error
|
|
479
|
+
except FileNotFoundError:
|
|
480
|
+
return PathEntry(relative_path, "absent")
|
|
481
|
+
if stat.S_ISLNK(mode):
|
|
482
|
+
try:
|
|
483
|
+
target = os.readlink(filename, dir_fd=parent_descriptor)
|
|
484
|
+
except (NotImplementedError, TypeError) as error:
|
|
485
|
+
raise RuntimeError(
|
|
486
|
+
"host cannot inspect repository links without following them"
|
|
487
|
+
) from error
|
|
488
|
+
return PathEntry(
|
|
489
|
+
relative_path,
|
|
490
|
+
"symlink",
|
|
491
|
+
target=os.fsdecode(target),
|
|
492
|
+
)
|
|
493
|
+
if stat.S_ISREG(mode):
|
|
494
|
+
return PathEntry(
|
|
495
|
+
relative_path,
|
|
496
|
+
"file",
|
|
497
|
+
mode=f"{stat.S_IMODE(mode):04o}",
|
|
498
|
+
)
|
|
499
|
+
return PathEntry(relative_path, "other")
|
|
500
|
+
finally:
|
|
501
|
+
os.close(parent_descriptor)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def worktree_path_entries(
|
|
505
|
+
repository_root: Path, paths: Sequence[str]
|
|
506
|
+
) -> tuple[PathEntry, ...]:
|
|
507
|
+
"""Describe every selected repository object without dereferencing links."""
|
|
508
|
+
return tuple(worktree_path_entry(repository_root, path) for path in paths)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def git_object_kind(mode: str, object_type: str) -> str:
|
|
512
|
+
"""Classify an immutable Git object without treating a link as a file."""
|
|
513
|
+
if mode == "120000":
|
|
514
|
+
return "git-symlink"
|
|
515
|
+
if mode == "160000" or object_type == "commit":
|
|
516
|
+
return "git-submodule"
|
|
517
|
+
if object_type == "blob":
|
|
518
|
+
return "git-blob"
|
|
519
|
+
return "git-other"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def nul_records(document: bytes) -> list[bytes]:
|
|
523
|
+
"""Split a Git NUL-delimited record stream while preserving path bytes."""
|
|
524
|
+
return [record for record in document.split(b"\0") if record]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def index_entry_map(
|
|
528
|
+
paths: Sequence[str], repository_root: Path
|
|
529
|
+
) -> dict[str, PathEntry]:
|
|
530
|
+
"""Return immutable index-object metadata without reading worktree bytes."""
|
|
531
|
+
arguments = ["ls-files", "--stage", "-z"]
|
|
532
|
+
entries: dict[str, PathEntry] = {}
|
|
533
|
+
for record in nul_records(
|
|
534
|
+
git_bytes(
|
|
535
|
+
*pathspec_arguments(arguments, paths), repository_root=repository_root
|
|
536
|
+
)
|
|
537
|
+
):
|
|
538
|
+
try:
|
|
539
|
+
header, raw_path = record.split(b"\t", maxsplit=1)
|
|
540
|
+
raw_mode, raw_object_id, raw_stage = header.split()
|
|
541
|
+
except ValueError as error:
|
|
542
|
+
raise RuntimeError(
|
|
543
|
+
"invalid Git index entry while resolving scope"
|
|
544
|
+
) from error
|
|
545
|
+
path = os.fsdecode(raw_path)
|
|
546
|
+
stage = raw_stage.decode("ascii")
|
|
547
|
+
if stage != "0":
|
|
548
|
+
raise RuntimeError(f"unmerged index entry in selected scope: {path}")
|
|
549
|
+
mode = raw_mode.decode("ascii")
|
|
550
|
+
object_id = raw_object_id.decode("ascii")
|
|
551
|
+
entries[path] = PathEntry(
|
|
552
|
+
path,
|
|
553
|
+
git_object_kind(mode, "commit" if mode == "160000" else "blob"),
|
|
554
|
+
object_id=object_id,
|
|
555
|
+
mode=mode,
|
|
556
|
+
)
|
|
557
|
+
return entries
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def index_path_entries(
|
|
561
|
+
paths: Sequence[str], repository_root: Path
|
|
562
|
+
) -> tuple[PathEntry, ...]:
|
|
563
|
+
"""Return immutable index-object metadata for each selected staged path."""
|
|
564
|
+
if not paths:
|
|
565
|
+
return ()
|
|
566
|
+
entries = index_entry_map(paths, repository_root)
|
|
567
|
+
return tuple(entries.get(path, PathEntry(path, "absent")) for path in paths)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def head_tree_entry_map(
|
|
571
|
+
head: str, paths: Sequence[str], repository_root: Path
|
|
572
|
+
) -> dict[str, PathEntry]:
|
|
573
|
+
"""Return immutable recursive head-tree metadata for selected paths."""
|
|
574
|
+
arguments = ["ls-tree", "-r", "-z", head]
|
|
575
|
+
entries: dict[str, PathEntry] = {}
|
|
576
|
+
for record in nul_records(
|
|
577
|
+
git_bytes(
|
|
578
|
+
*pathspec_arguments(arguments, paths), repository_root=repository_root
|
|
579
|
+
)
|
|
580
|
+
):
|
|
581
|
+
try:
|
|
582
|
+
header, raw_path = record.split(b"\t", maxsplit=1)
|
|
583
|
+
raw_mode, raw_type, raw_object_id = header.split()
|
|
584
|
+
except ValueError as error:
|
|
585
|
+
raise RuntimeError(
|
|
586
|
+
"invalid Git tree entry while resolving scope"
|
|
587
|
+
) from error
|
|
588
|
+
path = os.fsdecode(raw_path)
|
|
589
|
+
mode = raw_mode.decode("ascii")
|
|
590
|
+
object_type = raw_type.decode("ascii")
|
|
591
|
+
entries[path] = PathEntry(
|
|
592
|
+
path,
|
|
593
|
+
git_object_kind(mode, object_type),
|
|
594
|
+
object_id=raw_object_id.decode("ascii"),
|
|
595
|
+
mode=mode,
|
|
596
|
+
)
|
|
597
|
+
return entries
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def head_tree_path_entries(
|
|
601
|
+
head: str, paths: Sequence[str], repository_root: Path
|
|
602
|
+
) -> tuple[PathEntry, ...]:
|
|
603
|
+
"""Return immutable head-tree metadata for each selected range path."""
|
|
604
|
+
if not paths:
|
|
605
|
+
return ()
|
|
606
|
+
entries = head_tree_entry_map(head, paths, repository_root)
|
|
607
|
+
return tuple(entries.get(path, PathEntry(path, "absent")) for path in paths)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@dataclass(frozen=True)
|
|
611
|
+
class WorktreeMetadata:
|
|
612
|
+
"""Bounded immutable metadata needed to compare raw worktree candidates."""
|
|
613
|
+
|
|
614
|
+
head_entries: dict[str, PathEntry]
|
|
615
|
+
index_entries: dict[str, PathEntry]
|
|
616
|
+
skip_worktree_paths: frozenset[str]
|
|
617
|
+
intent_to_add_paths: frozenset[str]
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def add_worktree_candidate(candidates: set[str], path: str) -> None:
|
|
621
|
+
"""Bound worktree metadata memory before retaining another candidate path."""
|
|
622
|
+
if path in candidates:
|
|
623
|
+
return
|
|
624
|
+
if len(candidates) >= MAX_WORKTREE_CANDIDATES:
|
|
625
|
+
raise RuntimeError(
|
|
626
|
+
"worktree candidate limit "
|
|
627
|
+
f"({MAX_WORKTREE_CANDIDATES}) reached; rerun with narrower PATH arguments"
|
|
628
|
+
)
|
|
629
|
+
candidates.add(path)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def parse_head_tree_record(record: bytes) -> PathEntry:
|
|
633
|
+
"""Decode one streamed `git ls-tree -z` record."""
|
|
634
|
+
try:
|
|
635
|
+
header, raw_path = record.split(b"\t", maxsplit=1)
|
|
636
|
+
raw_mode, raw_type, raw_object_id = header.split()
|
|
637
|
+
except ValueError as error:
|
|
638
|
+
raise RuntimeError("invalid Git tree entry while resolving scope") from error
|
|
639
|
+
path = os.fsdecode(raw_path)
|
|
640
|
+
mode = raw_mode.decode("ascii")
|
|
641
|
+
object_type = raw_type.decode("ascii")
|
|
642
|
+
return PathEntry(
|
|
643
|
+
path,
|
|
644
|
+
git_object_kind(mode, object_type),
|
|
645
|
+
object_id=raw_object_id.decode("ascii"),
|
|
646
|
+
mode=mode,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def parse_tagged_index_record(record: bytes) -> tuple[PathEntry, bool]:
|
|
651
|
+
"""Decode one streamed `git ls-files --stage -t -z` record."""
|
|
652
|
+
try:
|
|
653
|
+
raw_tag, raw_entry = record.split(b" ", maxsplit=1)
|
|
654
|
+
header, raw_path = raw_entry.split(b"\t", maxsplit=1)
|
|
655
|
+
raw_mode, raw_object_id, raw_stage = header.split()
|
|
656
|
+
except ValueError as error:
|
|
657
|
+
raise RuntimeError("invalid Git index entry while resolving scope") from error
|
|
658
|
+
path = os.fsdecode(raw_path)
|
|
659
|
+
stage = raw_stage.decode("ascii")
|
|
660
|
+
if stage != "0":
|
|
661
|
+
raise RuntimeError(f"unmerged index entry in selected scope: {path}")
|
|
662
|
+
mode = raw_mode.decode("ascii")
|
|
663
|
+
return (
|
|
664
|
+
PathEntry(
|
|
665
|
+
path,
|
|
666
|
+
git_object_kind(mode, "commit" if mode == "160000" else "blob"),
|
|
667
|
+
object_id=raw_object_id.decode("ascii"),
|
|
668
|
+
mode=mode,
|
|
669
|
+
),
|
|
670
|
+
raw_tag == b"S",
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def worktree_metadata(
|
|
675
|
+
head: str, paths: Sequence[str], repository_root: Path
|
|
676
|
+
) -> WorktreeMetadata:
|
|
677
|
+
"""Stream bounded HEAD/index metadata without reading worktree bytes."""
|
|
678
|
+
candidates: set[str] = set()
|
|
679
|
+
head_entries: dict[str, PathEntry] = {}
|
|
680
|
+
index_entries: dict[str, PathEntry] = {}
|
|
681
|
+
skip_worktree_paths: set[str] = set()
|
|
682
|
+
staged_change_paths: set[str] = set()
|
|
683
|
+
|
|
684
|
+
def consume_head(record: bytes) -> None:
|
|
685
|
+
entry = parse_head_tree_record(record)
|
|
686
|
+
add_worktree_candidate(candidates, entry.path)
|
|
687
|
+
head_entries[entry.path] = entry
|
|
688
|
+
|
|
689
|
+
def consume_index(record: bytes) -> None:
|
|
690
|
+
entry, skip_worktree = parse_tagged_index_record(record)
|
|
691
|
+
add_worktree_candidate(candidates, entry.path)
|
|
692
|
+
index_entries[entry.path] = entry
|
|
693
|
+
if skip_worktree:
|
|
694
|
+
skip_worktree_paths.add(entry.path)
|
|
695
|
+
|
|
696
|
+
def consume_staged_change(record: bytes) -> None:
|
|
697
|
+
path = os.fsdecode(record)
|
|
698
|
+
if path not in candidates:
|
|
699
|
+
raise RuntimeError(
|
|
700
|
+
f"staged change path was missing from worktree scope metadata: {path}"
|
|
701
|
+
)
|
|
702
|
+
staged_change_paths.add(path)
|
|
703
|
+
|
|
704
|
+
consume_git_nul_records(
|
|
705
|
+
pathspec_arguments(["ls-tree", "-r", "-z", head], paths),
|
|
706
|
+
repository_root,
|
|
707
|
+
consume_head,
|
|
708
|
+
)
|
|
709
|
+
consume_git_nul_records(
|
|
710
|
+
pathspec_arguments(["ls-files", "--stage", "-t", "-z"], paths),
|
|
711
|
+
repository_root,
|
|
712
|
+
consume_index,
|
|
713
|
+
)
|
|
714
|
+
consume_git_nul_records(
|
|
715
|
+
pathspec_arguments(
|
|
716
|
+
[
|
|
717
|
+
"-c",
|
|
718
|
+
"diff.autoRefreshIndex=false",
|
|
719
|
+
"diff",
|
|
720
|
+
"--cached",
|
|
721
|
+
"--no-ext-diff",
|
|
722
|
+
"--no-textconv",
|
|
723
|
+
"--ignore-submodules=none",
|
|
724
|
+
"--name-only",
|
|
725
|
+
"-z",
|
|
726
|
+
"--no-renames",
|
|
727
|
+
"--ita-invisible-in-index",
|
|
728
|
+
head,
|
|
729
|
+
],
|
|
730
|
+
paths,
|
|
731
|
+
),
|
|
732
|
+
repository_root,
|
|
733
|
+
consume_staged_change,
|
|
734
|
+
)
|
|
735
|
+
intent_to_add_paths = frozenset(
|
|
736
|
+
path
|
|
737
|
+
for path in index_entries
|
|
738
|
+
if path not in head_entries and path not in staged_change_paths
|
|
739
|
+
)
|
|
740
|
+
return WorktreeMetadata(
|
|
741
|
+
head_entries=head_entries,
|
|
742
|
+
index_entries=index_entries,
|
|
743
|
+
skip_worktree_paths=frozenset(skip_worktree_paths),
|
|
744
|
+
intent_to_add_paths=intent_to_add_paths,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@dataclass(frozen=True)
|
|
749
|
+
class FileSnapshot:
|
|
750
|
+
"""A regular file's streamed content identity and optional Git blob OID."""
|
|
751
|
+
|
|
752
|
+
fingerprint: ContentFingerprint
|
|
753
|
+
object_id: str | None
|
|
754
|
+
mode: str
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@dataclass(frozen=True)
|
|
758
|
+
class WorktreePathSnapshot:
|
|
759
|
+
"""No-follow worktree metadata plus raw content identity where applicable."""
|
|
760
|
+
|
|
761
|
+
entry: PathEntry
|
|
762
|
+
content: ContentFingerprint | None = None
|
|
763
|
+
object_id: str | None = None
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@dataclass(frozen=True)
|
|
767
|
+
class WorktreeTrackedCapture:
|
|
768
|
+
"""One bounded representation of all worktree changes relative to HEAD."""
|
|
769
|
+
|
|
770
|
+
paths: tuple[str, ...]
|
|
771
|
+
fingerprint: ContentFingerprint
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def git_object_format(repository_root: Path) -> str:
|
|
775
|
+
"""Return a supported Git object hash format before hashing raw blobs."""
|
|
776
|
+
object_format = git_text(
|
|
777
|
+
"rev-parse", "--show-object-format", repository_root=repository_root
|
|
778
|
+
)
|
|
779
|
+
try:
|
|
780
|
+
hashlib.new(object_format)
|
|
781
|
+
except ValueError as error:
|
|
782
|
+
raise RuntimeError(
|
|
783
|
+
f"unsupported Git object format for worktree review: {object_format}"
|
|
784
|
+
) from error
|
|
785
|
+
return object_format
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def git_blob_object_id(contents: bytes, object_format: str) -> str:
|
|
789
|
+
"""Return the Git blob object ID for already-bounded bytes such as a link target."""
|
|
790
|
+
digest = hashlib.new(object_format)
|
|
791
|
+
digest.update(f"blob {len(contents)}\0".encode("ascii"))
|
|
792
|
+
digest.update(contents)
|
|
793
|
+
return digest.hexdigest()
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def stable_file_stat(before: os.stat_result, after: os.stat_result) -> bool:
|
|
797
|
+
"""Report whether a file descriptor retained its immutable read identity."""
|
|
798
|
+
return (
|
|
799
|
+
before.st_dev,
|
|
800
|
+
before.st_ino,
|
|
801
|
+
before.st_mode,
|
|
802
|
+
before.st_size,
|
|
803
|
+
before.st_mtime_ns,
|
|
804
|
+
before.st_ctime_ns,
|
|
805
|
+
) == (
|
|
806
|
+
after.st_dev,
|
|
807
|
+
after.st_ino,
|
|
808
|
+
after.st_mode,
|
|
809
|
+
after.st_size,
|
|
810
|
+
after.st_mtime_ns,
|
|
811
|
+
after.st_ctime_ns,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def read_regular_file_snapshot_without_following(
|
|
816
|
+
repository_root: Path,
|
|
817
|
+
relative_path: str,
|
|
818
|
+
object_format: str | None = None,
|
|
819
|
+
) -> FileSnapshot:
|
|
820
|
+
"""Fingerprint a regular file without following links or blocking on a FIFO."""
|
|
821
|
+
nonblocking_value = getattr(os, "O_NONBLOCK", None)
|
|
822
|
+
try:
|
|
823
|
+
if nonblocking_value is None:
|
|
824
|
+
raise TypeError
|
|
825
|
+
nonblocking_flag = index(nonblocking_value)
|
|
826
|
+
except TypeError as error:
|
|
827
|
+
raise RuntimeError(
|
|
828
|
+
"host cannot inspect repository files without nonblocking open support"
|
|
829
|
+
) from error
|
|
830
|
+
parent_descriptor, filename = nofollow_parent_descriptor(
|
|
831
|
+
repository_root, relative_path
|
|
832
|
+
)
|
|
833
|
+
descriptor: int | None = None
|
|
834
|
+
try:
|
|
835
|
+
try:
|
|
836
|
+
descriptor = os.open(
|
|
837
|
+
filename,
|
|
838
|
+
os.O_RDONLY | os.O_NOFOLLOW | nonblocking_flag,
|
|
839
|
+
dir_fd=parent_descriptor,
|
|
840
|
+
)
|
|
841
|
+
except (NotImplementedError, TypeError) as error:
|
|
842
|
+
raise RuntimeError(
|
|
843
|
+
"host cannot inspect repository paths without following links"
|
|
844
|
+
) from error
|
|
845
|
+
finally:
|
|
846
|
+
try:
|
|
847
|
+
os.close(parent_descriptor)
|
|
848
|
+
except OSError:
|
|
849
|
+
if descriptor is not None:
|
|
850
|
+
close_descriptor_quietly(descriptor)
|
|
851
|
+
raise
|
|
852
|
+
assert descriptor is not None
|
|
853
|
+
try:
|
|
854
|
+
initial_stat = os.fstat(descriptor)
|
|
855
|
+
if not stat.S_ISREG(initial_stat.st_mode):
|
|
856
|
+
raise RuntimeError(f"untracked path is not a regular file: {relative_path}")
|
|
857
|
+
content_digest = sha256()
|
|
858
|
+
object_digest = (
|
|
859
|
+
hashlib.new(object_format) if object_format is not None else None
|
|
860
|
+
)
|
|
861
|
+
if object_digest is not None:
|
|
862
|
+
object_digest.update(f"blob {initial_stat.st_size}\0".encode("ascii"))
|
|
863
|
+
content_length = 0
|
|
864
|
+
while chunk := os.read(descriptor, READ_CHUNK_SIZE):
|
|
865
|
+
content_digest.update(chunk)
|
|
866
|
+
if object_digest is not None:
|
|
867
|
+
object_digest.update(chunk)
|
|
868
|
+
content_length += len(chunk)
|
|
869
|
+
final_stat = os.fstat(descriptor)
|
|
870
|
+
if content_length != initial_stat.st_size or not stable_file_stat(
|
|
871
|
+
initial_stat, final_stat
|
|
872
|
+
):
|
|
873
|
+
raise RuntimeError(
|
|
874
|
+
f"repository file changed while resolving scope: {relative_path}"
|
|
875
|
+
)
|
|
876
|
+
return FileSnapshot(
|
|
877
|
+
fingerprint=ContentFingerprint(
|
|
878
|
+
length=content_length, digest=content_digest.hexdigest()
|
|
879
|
+
),
|
|
880
|
+
object_id=object_digest.hexdigest() if object_digest is not None else None,
|
|
881
|
+
mode=f"{stat.S_IMODE(final_stat.st_mode):04o}",
|
|
882
|
+
)
|
|
883
|
+
finally:
|
|
884
|
+
os.close(descriptor)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def read_regular_file_without_following(
|
|
888
|
+
repository_root: Path, relative_path: str
|
|
889
|
+
) -> ContentFingerprint:
|
|
890
|
+
"""Return a bounded content identity for a regular no-follow repository file."""
|
|
891
|
+
return read_regular_file_snapshot_without_following(
|
|
892
|
+
repository_root, relative_path
|
|
893
|
+
).fingerprint
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def worktree_path_snapshot(
|
|
897
|
+
repository_root: Path, relative_path: str, object_format: str
|
|
898
|
+
) -> WorktreePathSnapshot:
|
|
899
|
+
"""Capture raw worktree state without asking Git to convert a worktree file."""
|
|
900
|
+
entry = worktree_path_entry(repository_root, relative_path)
|
|
901
|
+
if entry.kind == "file":
|
|
902
|
+
snapshot = read_regular_file_snapshot_without_following(
|
|
903
|
+
repository_root, relative_path, object_format
|
|
904
|
+
)
|
|
905
|
+
return WorktreePathSnapshot(
|
|
906
|
+
entry=PathEntry(entry.path, "file", mode=snapshot.mode),
|
|
907
|
+
content=snapshot.fingerprint,
|
|
908
|
+
object_id=snapshot.object_id,
|
|
909
|
+
)
|
|
910
|
+
if entry.kind == "symlink":
|
|
911
|
+
if entry.target is None:
|
|
912
|
+
raise RuntimeError(
|
|
913
|
+
f"repository link changed while resolving scope: {relative_path}"
|
|
914
|
+
)
|
|
915
|
+
contents = os.fsencode(entry.target)
|
|
916
|
+
return WorktreePathSnapshot(
|
|
917
|
+
entry=entry,
|
|
918
|
+
content=content_fingerprint((contents,)),
|
|
919
|
+
object_id=git_blob_object_id(contents, object_format),
|
|
920
|
+
)
|
|
921
|
+
return WorktreePathSnapshot(entry=entry)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def git_mode_for_worktree_file(entry: PathEntry) -> str:
|
|
925
|
+
"""Map a regular filesystem mode to Git's executable-bit-only mode."""
|
|
926
|
+
if entry.kind != "file" or entry.mode is None:
|
|
927
|
+
raise RuntimeError(f"invalid worktree file entry: {entry.path}")
|
|
928
|
+
return "100755" if int(entry.mode, 8) & 0o111 else "100644"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def worktree_matches_entry(
|
|
932
|
+
snapshot: WorktreePathSnapshot, tree_entry: PathEntry | None
|
|
933
|
+
) -> bool:
|
|
934
|
+
"""Compare raw no-follow worktree state to one immutable tree entry."""
|
|
935
|
+
if tree_entry is None:
|
|
936
|
+
return snapshot.entry.kind == "absent"
|
|
937
|
+
if tree_entry.kind == "git-blob":
|
|
938
|
+
return (
|
|
939
|
+
snapshot.entry.kind == "file"
|
|
940
|
+
and snapshot.object_id == tree_entry.object_id
|
|
941
|
+
and git_mode_for_worktree_file(snapshot.entry) == tree_entry.mode
|
|
942
|
+
)
|
|
943
|
+
if tree_entry.kind == "git-symlink":
|
|
944
|
+
return (
|
|
945
|
+
snapshot.entry.kind == "symlink"
|
|
946
|
+
and snapshot.object_id == tree_entry.object_id
|
|
947
|
+
and tree_entry.mode == "120000"
|
|
948
|
+
)
|
|
949
|
+
raise RuntimeError(
|
|
950
|
+
f"worktree scope cannot safely compare Git object kind for {tree_entry.path}"
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def add_content_fingerprint(
|
|
955
|
+
digest: Digest, label: bytes, fingerprint: ContentFingerprint
|
|
956
|
+
) -> None:
|
|
957
|
+
"""Frame a stream identity without retaining its original bytes."""
|
|
958
|
+
add_digest_part(digest, label + b"-length", str(fingerprint.length).encode("ascii"))
|
|
959
|
+
add_digest_part(digest, label + b"-sha256", fingerprint.digest.encode("ascii"))
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def add_worktree_snapshot(digest: Digest, snapshot: WorktreePathSnapshot) -> None:
|
|
963
|
+
"""Bind raw worktree metadata and content to a tracked-capture digest."""
|
|
964
|
+
entry = snapshot.entry
|
|
965
|
+
add_digest_part(digest, b"path", os.fsencode(entry.path))
|
|
966
|
+
add_digest_part(digest, b"kind", entry.kind.encode("ascii"))
|
|
967
|
+
if entry.target is not None:
|
|
968
|
+
add_digest_part(digest, b"target", os.fsencode(entry.target))
|
|
969
|
+
if entry.mode is not None:
|
|
970
|
+
add_digest_part(digest, b"mode", entry.mode.encode("ascii"))
|
|
971
|
+
if snapshot.object_id is not None:
|
|
972
|
+
add_digest_part(digest, b"object-id", snapshot.object_id.encode("ascii"))
|
|
973
|
+
if snapshot.content is not None:
|
|
974
|
+
add_content_fingerprint(digest, b"content", snapshot.content)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def worktree_tracked_capture(
|
|
978
|
+
head: str, paths: Sequence[str], repository_root: Path
|
|
979
|
+
) -> WorktreeTrackedCapture:
|
|
980
|
+
"""Compare raw worktree state to HEAD without invoking Git diff filters."""
|
|
981
|
+
object_format = git_object_format(repository_root)
|
|
982
|
+
metadata = worktree_metadata(head, paths, repository_root)
|
|
983
|
+
candidates = sorted(set(metadata.head_entries).union(metadata.index_entries))
|
|
984
|
+
digest = sha256()
|
|
985
|
+
add_digest_part(digest, b"schema", b"athena-change-review-worktree-v1")
|
|
986
|
+
add_digest_part(digest, b"object-format", object_format.encode("ascii"))
|
|
987
|
+
selected_paths: list[str] = []
|
|
988
|
+
content_length = 0
|
|
989
|
+
for path in candidates:
|
|
990
|
+
snapshot = worktree_path_snapshot(repository_root, path, object_format)
|
|
991
|
+
head_entry = metadata.head_entries.get(path)
|
|
992
|
+
index_entry = metadata.index_entries.get(path)
|
|
993
|
+
if (
|
|
994
|
+
head_entry is not None
|
|
995
|
+
and head_entry.kind == "git-submodule"
|
|
996
|
+
or index_entry is not None
|
|
997
|
+
and index_entry.kind == "git-submodule"
|
|
998
|
+
):
|
|
999
|
+
raise RuntimeError(
|
|
1000
|
+
"worktree scope cannot safely determine submodule state for "
|
|
1001
|
+
f"{path}; use --staged or --range"
|
|
1002
|
+
)
|
|
1003
|
+
index_differs_from_head = index_entry != head_entry
|
|
1004
|
+
if path in metadata.skip_worktree_paths and snapshot.entry.kind == "absent":
|
|
1005
|
+
if index_differs_from_head:
|
|
1006
|
+
raise RuntimeError(
|
|
1007
|
+
"worktree scope cannot safely inspect staged change in "
|
|
1008
|
+
f"skip-worktree path {path}; use --staged"
|
|
1009
|
+
)
|
|
1010
|
+
continue
|
|
1011
|
+
if (
|
|
1012
|
+
index_differs_from_head
|
|
1013
|
+
and path not in metadata.intent_to_add_paths
|
|
1014
|
+
and not worktree_matches_entry(snapshot, index_entry)
|
|
1015
|
+
):
|
|
1016
|
+
raise RuntimeError(
|
|
1017
|
+
"worktree scope cannot safely inspect staged change whose live "
|
|
1018
|
+
f"bytes differ from the index for {path}; use --staged"
|
|
1019
|
+
)
|
|
1020
|
+
if worktree_matches_entry(snapshot, head_entry):
|
|
1021
|
+
continue
|
|
1022
|
+
selected_paths.append(path)
|
|
1023
|
+
add_worktree_snapshot(digest, snapshot)
|
|
1024
|
+
if snapshot.content is not None:
|
|
1025
|
+
content_length += snapshot.content.length
|
|
1026
|
+
return WorktreeTrackedCapture(
|
|
1027
|
+
paths=tuple(selected_paths),
|
|
1028
|
+
fingerprint=ContentFingerprint(
|
|
1029
|
+
length=content_length, digest=digest.hexdigest()
|
|
1030
|
+
),
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def untracked_content(
|
|
1035
|
+
repository_root: Path, relative_path: str
|
|
1036
|
+
) -> tuple[bytes, ContentFingerprint]:
|
|
1037
|
+
"""Return a bounded no-follow content representation for an untracked path."""
|
|
1038
|
+
entry = worktree_path_entry(repository_root, relative_path)
|
|
1039
|
+
if entry.kind == "symlink":
|
|
1040
|
+
if entry.target is None:
|
|
1041
|
+
raise RuntimeError(
|
|
1042
|
+
f"untracked link changed while resolving scope: {relative_path}"
|
|
1043
|
+
)
|
|
1044
|
+
return b"symlink", content_fingerprint((os.fsencode(entry.target),))
|
|
1045
|
+
if entry.kind == "file":
|
|
1046
|
+
return b"file", read_regular_file_without_following(
|
|
1047
|
+
repository_root, relative_path
|
|
1048
|
+
)
|
|
1049
|
+
raise RuntimeError(f"untracked path changed while resolving scope: {relative_path}")
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def scope_digest(
|
|
1053
|
+
scope: str,
|
|
1054
|
+
base: str,
|
|
1055
|
+
head: str,
|
|
1056
|
+
paths: Sequence[str],
|
|
1057
|
+
tracked: ContentFingerprint,
|
|
1058
|
+
repository_root: Path,
|
|
1059
|
+
entries: Sequence[PathEntry],
|
|
1060
|
+
untracked: Sequence[str],
|
|
1061
|
+
) -> str:
|
|
1062
|
+
"""Bind the selected manifest, tracked diff, and untracked contents to SHA-256."""
|
|
1063
|
+
digest = sha256()
|
|
1064
|
+
add_digest_part(digest, b"schema", b"athena-change-review-scope-v3")
|
|
1065
|
+
add_digest_part(digest, b"scope", scope.encode("utf-8"))
|
|
1066
|
+
add_digest_part(digest, b"base", base.encode("ascii"))
|
|
1067
|
+
add_digest_part(digest, b"head", head.encode("ascii"))
|
|
1068
|
+
for path in paths:
|
|
1069
|
+
add_digest_part(digest, b"path", os.fsencode(path))
|
|
1070
|
+
add_content_fingerprint(digest, b"tracked-diff", tracked)
|
|
1071
|
+
for entry in entries:
|
|
1072
|
+
add_digest_part(digest, b"entry-path", os.fsencode(entry.path))
|
|
1073
|
+
add_digest_part(digest, b"entry-kind", entry.kind.encode("ascii"))
|
|
1074
|
+
if entry.target is not None:
|
|
1075
|
+
add_digest_part(digest, b"entry-target", os.fsencode(entry.target))
|
|
1076
|
+
if entry.object_id is not None:
|
|
1077
|
+
add_digest_part(digest, b"entry-object-id", entry.object_id.encode("ascii"))
|
|
1078
|
+
if entry.mode is not None:
|
|
1079
|
+
add_digest_part(digest, b"entry-mode", entry.mode.encode("ascii"))
|
|
1080
|
+
for path in untracked:
|
|
1081
|
+
kind, content = untracked_content(repository_root, path)
|
|
1082
|
+
add_digest_part(digest, b"untracked-path", os.fsencode(path))
|
|
1083
|
+
add_digest_part(digest, b"untracked-kind", kind)
|
|
1084
|
+
add_content_fingerprint(digest, b"untracked-content", content)
|
|
1085
|
+
return digest.hexdigest()
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def capture_scope(
|
|
1089
|
+
scope: str,
|
|
1090
|
+
base: str,
|
|
1091
|
+
head: str,
|
|
1092
|
+
paths: Sequence[str],
|
|
1093
|
+
repository_root: Path,
|
|
1094
|
+
) -> ScopeCapture:
|
|
1095
|
+
"""Capture one complete scope observation for a later stability comparison."""
|
|
1096
|
+
if scope == "worktree":
|
|
1097
|
+
tracked_capture = worktree_tracked_capture(head, paths, repository_root)
|
|
1098
|
+
selected_tracked_paths = list(tracked_capture.paths)
|
|
1099
|
+
tracked = tracked_capture.fingerprint
|
|
1100
|
+
else:
|
|
1101
|
+
selected_tracked_paths = tracked_paths(
|
|
1102
|
+
scope, base, head, paths, repository_root
|
|
1103
|
+
)
|
|
1104
|
+
tracked = tracked_diff(scope, base, head, paths, repository_root)
|
|
1105
|
+
selected_untracked_paths = (
|
|
1106
|
+
untracked_paths(paths, repository_root) if scope == "worktree" else []
|
|
1107
|
+
)
|
|
1108
|
+
all_paths = sorted(set(selected_tracked_paths).union(selected_untracked_paths))
|
|
1109
|
+
if scope == "worktree":
|
|
1110
|
+
entries = worktree_path_entries(repository_root, all_paths)
|
|
1111
|
+
elif scope == "staged":
|
|
1112
|
+
entries = index_path_entries(all_paths, repository_root)
|
|
1113
|
+
else:
|
|
1114
|
+
entries = head_tree_path_entries(head, all_paths, repository_root)
|
|
1115
|
+
return ScopeCapture(
|
|
1116
|
+
paths=tuple(all_paths),
|
|
1117
|
+
tracked_paths=tuple(selected_tracked_paths),
|
|
1118
|
+
untracked_paths=tuple(selected_untracked_paths),
|
|
1119
|
+
path_entries=entries,
|
|
1120
|
+
tracked_diff=tracked,
|
|
1121
|
+
scope_digest=scope_digest(
|
|
1122
|
+
scope,
|
|
1123
|
+
base,
|
|
1124
|
+
head,
|
|
1125
|
+
all_paths,
|
|
1126
|
+
tracked,
|
|
1127
|
+
repository_root,
|
|
1128
|
+
entries,
|
|
1129
|
+
selected_untracked_paths,
|
|
1130
|
+
),
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def entry_documents(entries: Sequence[PathEntry]) -> list[dict[str, str]]:
|
|
1135
|
+
"""Render no-follow path metadata for the JSON scope manifest."""
|
|
1136
|
+
documents: list[dict[str, str]] = []
|
|
1137
|
+
for entry in entries:
|
|
1138
|
+
document = {"kind": entry.kind, "path": entry.path}
|
|
1139
|
+
if entry.target is not None:
|
|
1140
|
+
document["target"] = entry.target
|
|
1141
|
+
if entry.object_id is not None:
|
|
1142
|
+
document["object_id"] = entry.object_id
|
|
1143
|
+
if entry.mode is not None:
|
|
1144
|
+
document["mode"] = entry.mode
|
|
1145
|
+
documents.append(document)
|
|
1146
|
+
return documents
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def resolve_scope(
|
|
1150
|
+
scope: str, range_value: str | None, selected_paths: Sequence[str]
|
|
1151
|
+
) -> dict[str, object]:
|
|
1152
|
+
"""Resolve the selected paths and content identity for one review scope."""
|
|
1153
|
+
repository_root = Path(git_text("rev-parse", "--show-toplevel")).resolve()
|
|
1154
|
+
paths = normalized_paths(repository_root, selected_paths)
|
|
1155
|
+
if scope == "range":
|
|
1156
|
+
if range_value is None:
|
|
1157
|
+
raise RuntimeError("range scope requires BASE..HEAD")
|
|
1158
|
+
base, head = range_revisions(range_value, repository_root)
|
|
1159
|
+
else:
|
|
1160
|
+
head = verified_commit("HEAD", repository_root)
|
|
1161
|
+
base = head
|
|
1162
|
+
|
|
1163
|
+
first_capture = capture_scope(scope, base, head, paths, repository_root)
|
|
1164
|
+
second_capture = capture_scope(scope, base, head, paths, repository_root)
|
|
1165
|
+
if first_capture != second_capture:
|
|
1166
|
+
raise RuntimeError("change scope changed while resolving; retry the review")
|
|
1167
|
+
if scope != "range" and verified_commit("HEAD", repository_root) != head:
|
|
1168
|
+
raise RuntimeError("HEAD changed while resolving; retry the review")
|
|
1169
|
+
return {
|
|
1170
|
+
"base": base,
|
|
1171
|
+
"content_source": (
|
|
1172
|
+
"worktree"
|
|
1173
|
+
if scope == "worktree"
|
|
1174
|
+
else "index"
|
|
1175
|
+
if scope == "staged"
|
|
1176
|
+
else "head-tree"
|
|
1177
|
+
),
|
|
1178
|
+
"head": head,
|
|
1179
|
+
"path_entries": entry_documents(second_capture.path_entries),
|
|
1180
|
+
"paths": list(second_capture.paths),
|
|
1181
|
+
"scope": scope,
|
|
1182
|
+
"scope_digest": second_capture.scope_digest,
|
|
1183
|
+
"tracked_paths": list(second_capture.tracked_paths),
|
|
1184
|
+
"untracked_paths": list(second_capture.untracked_paths),
|
|
1185
|
+
"untracked_scope": "included" if scope == "worktree" else "excluded",
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1190
|
+
"""Parse the requested review scope and return its JSON manifest."""
|
|
1191
|
+
parser = argument_parser(description=__doc__)
|
|
1192
|
+
scope_group = parser.add_mutually_exclusive_group()
|
|
1193
|
+
scope_group.add_argument("--worktree", action="store_true")
|
|
1194
|
+
scope_group.add_argument("--staged", action="store_true")
|
|
1195
|
+
scope_group.add_argument("--range", dest="range_value", metavar="BASE..HEAD")
|
|
1196
|
+
parser.add_argument("paths", metavar="PATH", nargs="*")
|
|
1197
|
+
arguments = parser.parse_args(argv)
|
|
1198
|
+
scope = (
|
|
1199
|
+
"range"
|
|
1200
|
+
if arguments.range_value is not None
|
|
1201
|
+
else "staged"
|
|
1202
|
+
if arguments.staged
|
|
1203
|
+
else "worktree"
|
|
1204
|
+
)
|
|
1205
|
+
try:
|
|
1206
|
+
print(
|
|
1207
|
+
json.dumps(
|
|
1208
|
+
resolve_scope(scope, arguments.range_value, arguments.paths),
|
|
1209
|
+
sort_keys=True,
|
|
1210
|
+
)
|
|
1211
|
+
)
|
|
1212
|
+
except (OSError, RuntimeError) as error:
|
|
1213
|
+
print(error, file=sys.stderr)
|
|
1214
|
+
return 1
|
|
1215
|
+
return 0
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
if __name__ == "__main__":
|
|
1219
|
+
raise SystemExit(main())
|