@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.
Files changed (36) hide show
  1. package/LICENSE +29 -0
  2. package/NOTICE +22 -0
  3. package/README.md +45 -0
  4. package/package.json +29 -0
  5. package/plugin.js +54 -0
  6. package/skills/THIRD_PARTY_LICENSES.md +50 -0
  7. package/skills/_cli.py +152 -0
  8. package/skills/advise/SKILL.md +62 -0
  9. package/skills/advise/scripts/list_retrievable_skills.py +49 -0
  10. package/skills/brainstorm/SKILL.md +110 -0
  11. package/skills/change-review/SKILL.md +68 -0
  12. package/skills/change-review/references/scope-resolution.md +52 -0
  13. package/skills/change-review/scripts/resolve_scope.py +1219 -0
  14. package/skills/finalize-plan/SKILL.md +129 -0
  15. package/skills/git-worktrees/SKILL.md +113 -0
  16. package/skills/git-worktrees/scripts/prepare_worktree.py +153 -0
  17. package/skills/issue-review/SKILL.md +67 -0
  18. package/skills/learn/SKILL.md +208 -0
  19. package/skills/myrmidon-swarm/SKILL.md +93 -0
  20. package/skills/plan-issue/SKILL.md +70 -0
  21. package/skills/pr-review/SKILL.md +114 -0
  22. package/skills/pr-review/references/criteria.md +26 -0
  23. package/skills/pr-review/references/delivery.md +135 -0
  24. package/skills/pr-review/references/evidence.md +233 -0
  25. package/skills/pr-review/references/prevalidated.md +155 -0
  26. package/skills/pr-review/scripts/collect_evidence.py +1478 -0
  27. package/skills/pr-review/scripts/diff_context.py +74 -0
  28. package/skills/pr-review/scripts/materialize_snapshot.py +731 -0
  29. package/skills/pr-review/scripts/pr_identity.py +80 -0
  30. package/skills/pr-review/scripts/resolve_pr.py +258 -0
  31. package/skills/repo-review/SKILL.md +119 -0
  32. package/skills/systematic-debugging/SKILL.md +199 -0
  33. package/skills/systematic-debugging/scripts/repository_evidence.py +77 -0
  34. package/skills/test-driven-development/SKILL.md +75 -0
  35. package/skills/tidy/SKILL.md +71 -0
  36. 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())