syncade 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/snapshot.py ADDED
@@ -0,0 +1,598 @@
1
+ """Snapshot the git state at the start of a syncade run.
2
+
3
+ A :class:`Snapshot` is a frozen value object that records exactly what
4
+ :mod:`syncade.orchestrator` needs to reproduce the worktrees a reviewer sees:
5
+ which commit they're checked out at, what branch (if any) the
6
+ run originated from, and — if the user supplied ``--base <ref>`` — the
7
+ diff to render into the reviewer prompt.
8
+
9
+ This module deliberately does NOT use :func:`subprocess.run` directly.
10
+ Every git call goes through :func:`syncade.process.run_subprocess` so
11
+ the shared subprocess machinery (timeout handling, error classification,
12
+ process-group cleanup) is exercised consistently across the codebase.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Final, Literal
21
+
22
+ from syncade.git_object_id import is_full_git_object_id
23
+ from syncade.process import (
24
+ SubprocessNotFoundError,
25
+ run_subprocess,
26
+ )
27
+
28
+ DirtyState = Literal["clean", "tracked", "untracked", "both"]
29
+ """Four-state classification of ``git status --porcelain`` output.
30
+
31
+ - ``"clean"`` — empty porcelain output.
32
+ - ``"tracked"`` — at least one line, all non-``??`` (modifications
33
+ and/or staged changes to tracked files). The actually-dangerous
34
+ case: the operator has local code changes the reviewers cannot
35
+ see at HEAD. Strong warning surface.
36
+ - ``"untracked"`` — at least one line, all starting with ``??``.
37
+ The alpha-briefings case: the operator has scratch files the
38
+ reviewers cannot see at HEAD, which is usually intentional. Soft
39
+ note surface.
40
+ - ``"both"`` — lines of both kinds present. The operator should
41
+ know about both. Emits BOTH messages (strong first, soft
42
+ second)."""
43
+
44
+ # Wall-clock ceiling for any single git invocation made by this module.
45
+ # `git diff` against a large base ref can run for several seconds on a
46
+ # big repo; everything else is sub-second. 30s is generous without
47
+ # being indefinite.
48
+ _GIT_TIMEOUT_SECONDS: float = 30.0
49
+
50
+ _NORMALIZED_DIFF_ARGS: Final[tuple[str, ...]] = (
51
+ # Object resolution, BEFORE the subcommand. `refs/replace/*` silently
52
+ # substitutes one object for another at every lookup, and it lives in the
53
+ # shared common dir, so it is writable from a producer worktree. Without
54
+ # this the diff (and `git worktree add <sha>`) can describe an entirely
55
+ # different commit than `Snapshot.commit_sha` — verified: a backdoored
56
+ # commit reviewed as its benign replacement, while the SHA that lands
57
+ # upstream still carries the backdoor.
58
+ "--no-replace-objects",
59
+ # Pin every setting that demonstrably changes the diff BYTES. `-c`
60
+ # outranks every config file, so a repo-local `.git/config` cannot move
61
+ # them. Each was verified to alter output when left unpinned.
62
+ "-c",
63
+ "diff.noprefix=false",
64
+ "-c",
65
+ "diff.mnemonicPrefix=false",
66
+ "-c",
67
+ "diff.srcPrefix=a/", # else the a/ b/ the strip filter matches on move
68
+ "-c",
69
+ "diff.dstPrefix=b/", # ...and repo-context files leak to blind reviewers
70
+ "-c",
71
+ "diff.context=3",
72
+ "-c",
73
+ "diff.interHunkContext=0",
74
+ "-c",
75
+ "core.abbrev=7",
76
+ "-c",
77
+ "diff.algorithm=myers",
78
+ "-c",
79
+ "diff.orderFile=/dev/null", # empty string is fatal to git; /dev/null is inert
80
+ "-c",
81
+ "core.bigFileThreshold=512m",
82
+ "-c",
83
+ "core.quotePath=true", # false → non-ASCII path headers change bytes
84
+ "-c",
85
+ "diff.renames=true", # false → renames expand to delete+add hunks
86
+ "-c",
87
+ "diff.suppressBlankEmpty=false", # true → blank context lines lose their trailing space
88
+ "-c",
89
+ "diff.submodule=short", # log/diff → rewrites submodule pointer diff to prose/expanded form
90
+ "-c",
91
+ "diff.ignoreSubmodules=none", # all → submodule pointer bumps disappear entirely
92
+ "-c",
93
+ "diff.indentHeuristic=true", # false → hunk-boundary placement changes; pin to modern default
94
+ "-c",
95
+ "diff.renameLimit=1000", # low values turn detected renames back into delete+add pairs
96
+ "-c",
97
+ "core.attributesFile=/dev/null",
98
+ "diff",
99
+ "--no-color",
100
+ # `diff.external` / textconv drivers hand the diff to an arbitrary program
101
+ # and use ITS stdout.
102
+ "--no-ext-diff",
103
+ "--no-textconv",
104
+ # `--text` is the ONLY lever against attribute-driven suppression: a `-diff`
105
+ # attribute in `.git/info/attributes` or a COMMITTED `.gitattributes`
106
+ # collapses a whole change to "Binary files ... differ", and git has no flag
107
+ # to ignore attributes files. Pinning `core.attributesFile` does not reach
108
+ # either source — verified. The cost is that a genuine binary is emitted as
109
+ # text, which makes the diff size cap (PR-h-02 increment E) load-bearing
110
+ # rather than a nicety.
111
+ "--text",
112
+ # Explicit flag so it outranks per-submodule `ignore` settings from
113
+ # `.gitmodules` or `submodule.<name>.ignore` in `.git/config`. The `-c`
114
+ # pin above overrides the global config key but NOT the per-submodule key;
115
+ # the command-line flag is the highest-precedence override.
116
+ "--ignore-submodules=none",
117
+ )
118
+ """Deny-list, and it is one on purpose — say so rather than imply otherwise.
119
+
120
+ `-c` can only pin keys we know about; a future git release can add another.
121
+ The structural fix is to compute the diff where `.git/config` is ours rather
122
+ than the reviewed repo's, which is PR-h-05's separate-clone work. Until then
123
+ this closes every vector reproduced against `6bb2890`, and new ones are a
124
+ matter of adding a line here.
125
+
126
+ **Known remaining vector: `diff.<driver>.xfuncname` hunk headers.** A
127
+ committed `.gitattributes` selecting an arbitrary diff driver, combined with
128
+ `diff.<driver>.xfuncname` in `.git/config`, changes the function-context
129
+ suffix of `@@ -N,M +N,M @@` lines. The driver name is arbitrary so it cannot
130
+ be pinned via `-c`. `_strip_hunk_function_context()` removes this suffix in
131
+ post-processing, making `diff_text` byte-deterministic with respect to any
132
+ xfuncname configuration.
133
+ """
134
+
135
+ # Matches the optional function-context suffix on unified-diff @@ lines,
136
+ # e.g. `@@ -1,4 +1,4 @@ def foo():` → captures `@@ -1,4 +1,4 @@`.
137
+ _HUNK_HEADER_RE: Final = re.compile(r"^(@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@).*$", re.MULTILINE)
138
+
139
+
140
+ def _strip_hunk_function_context(diff_text: str) -> str:
141
+ """Remove the optional function-context suffix from unified-diff @@ lines.
142
+
143
+ Git appends a function name (from built-in language detection or a
144
+ repo-configured ``diff.<driver>.xfuncname`` regex) to each hunk header.
145
+ A committed ``.gitattributes`` assigning an arbitrary driver combined with
146
+ a matching ``diff.<driver>.xfuncname`` in ``.git/config`` changes those
147
+ bytes in a way no ``-c`` flag can enumerate.
148
+
149
+ Stripping the suffix here makes ``diff_text`` byte-deterministic.
150
+ Reviewers retain file name, line numbers, and all context/changed lines;
151
+ only the redundant function-name hint in the ``@@`` header is removed.
152
+ """
153
+ return _HUNK_HEADER_RE.sub(r"\1", diff_text)
154
+
155
+
156
+ class SnapshotError(Exception):
157
+ """Raised when snapshotting fails.
158
+
159
+ Covers: cwd is not a git repository, HEAD is unresolvable (empty
160
+ repo), the supplied ``base_ref`` doesn't exist, or git itself isn't
161
+ installed. The message always includes the underlying git stderr
162
+ (trimmed) when applicable so the CLI can surface a useful error
163
+ without further introspection.
164
+ """
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class Snapshot:
169
+ """Frozen record of the repo state at the moment a syncade run started.
170
+
171
+ Captures exactly what's needed to (a) reproduce the worktrees the
172
+ reviewers see and (b) populate the reviewer prompt's diff section
173
+ when a base ref was supplied.
174
+
175
+ Attributes:
176
+ repo_root: Absolute path to the repo the snapshot was taken in.
177
+ commit_sha: Full HEAD object ID at snapshot time. Always the
178
+ canonical form, even if the caller supplied a short SHA
179
+ elsewhere — ``git rev-parse HEAD`` is the source of truth.
180
+ branch: The branch name, or ``None`` for detached HEAD. A
181
+ detached HEAD is a legitimate state for a CI-style run
182
+ (e.g. reviewing a tag); the orchestrator handles both.
183
+ base_ref: The ``--base`` value the caller supplied, or ``None``
184
+ if no diff was requested. Preserved verbatim so it can be
185
+ echoed back in the run manifest.
186
+ diff_text: ``git diff <base-oid>..<commit_sha>`` stdout when
187
+ ``base_ref`` was supplied; the empty string otherwise.
188
+ Running without a diff is supported — reviewers fall back
189
+ to reviewing the full HEAD state.
190
+ dirty_state: Four-state classification of the working tree. See
191
+ :data:`DirtyState` for the semantics. The orchestrator branches on
192
+ this to choose between a strong
193
+ warning (tracked-modified — the actually-dangerous case)
194
+ and a soft note (untracked-only — usually intentional),
195
+ instead of conflating both via a single warning string.
196
+ untracked_count: Number of untracked files at snapshot time.
197
+ The soft dirty-tree note includes this count ("working
198
+ tree has untracked files (not reviewed): <count>
199
+ file(s)..."). Captured here once so callers don't have
200
+ to re-parse porcelain to compute it. ``0`` when
201
+ ``dirty_state`` is ``"clean"`` or ``"tracked"``.
202
+ base_oid: The full object ID the diff was ACTUALLY taken against,
203
+ or ``None`` when no ``base_ref`` was supplied. Under the default
204
+ three-dot semantics this is the BRANCH POINT (the merge base of
205
+ ``base_ref`` and HEAD), not the tip of ``base_ref``; under
206
+ ``--two-dot`` the two coincide. Reading it as "the diff base" is
207
+ correct in both modes. Unlike
208
+ ``base_ref`` (the symbolic name), this value is immutable:
209
+ even if the ref moves after the snapshot, the diff was
210
+ computed against exactly this commit. Persisted in round
211
+ and loop manifests alongside ``base_ref`` so artifact
212
+ readers can reconstruct the exact reviewed range.
213
+ """
214
+
215
+ repo_root: Path
216
+ commit_sha: str
217
+ branch: str | None
218
+ base_ref: str | None
219
+ diff_text: str
220
+ dirty_state: DirtyState
221
+ untracked_count: int = 0
222
+ base_oid: str | None = None
223
+
224
+
225
+ def _git(repo_root: Path, *args: str) -> tuple[int, str, str]:
226
+ """Run ``git <args>`` in ``repo_root`` and return (rc, stdout, stderr).
227
+
228
+ Raises :class:`SnapshotError` if the ``git`` binary itself is
229
+ missing — the orchestrator can't snapshot anything without it.
230
+ """
231
+ try:
232
+ result = run_subprocess(
233
+ ["git", *args],
234
+ cwd=repo_root,
235
+ timeout=_GIT_TIMEOUT_SECONDS,
236
+ )
237
+ except SubprocessNotFoundError as exc:
238
+ raise SnapshotError("git binary not found on PATH — install git to use syncade") from exc
239
+ return result.returncode, result.stdout, result.stderr
240
+
241
+
242
+ def discover_repo_root(start_path: Path) -> Path:
243
+ """Resolve ``start_path`` to the root of the git repo it lives in.
244
+
245
+ Runs ``git rev-parse --show-toplevel`` from ``start_path`` and returns
246
+ the resolved repo root. This is the canonical way for the rest of the
247
+ orchestrator to convert a user-supplied path — which may point at any
248
+ subdirectory of the repo (e.g. the user ran ``syncade`` from
249
+ ``repo/docs/reviews/``) — into the repo-root path that ``.syncade/``
250
+ artifacts and worktree operations must be anchored to. The
251
+ user-supplied value is a *starting hint*, not the canonical root.
252
+
253
+ Args:
254
+ start_path: Any path inside the git working tree. Typically the
255
+ user's cwd or the ``--repo-root`` value. Must be an existing
256
+ directory.
257
+
258
+ Returns:
259
+ The absolute, resolved path of the repo root — the directory
260
+ ``git rev-parse --show-toplevel`` reports.
261
+
262
+ Raises:
263
+ SnapshotError: If ``start_path`` does not exist, is not a
264
+ directory, is not inside a git working tree, or git itself
265
+ isn't installed. The message includes git's own stderr
266
+ where available.
267
+ """
268
+ if not start_path.exists():
269
+ raise SnapshotError(f"start_path does not exist: {start_path}")
270
+ if not start_path.is_dir():
271
+ raise SnapshotError(f"start_path is not a directory: {start_path}")
272
+
273
+ rc, toplevel_stdout, toplevel_stderr = _git(start_path, "rev-parse", "--show-toplevel")
274
+ if rc != 0:
275
+ # `git rev-parse --show-toplevel` outside a repo emits
276
+ # "fatal: not a git repository (or any of the parent ...)".
277
+ # Surface git's own message so the user can disambiguate.
278
+ raise SnapshotError(
279
+ f"{start_path} is not inside a git repository: {toplevel_stderr.strip()}"
280
+ )
281
+ return Path(toplevel_stdout.strip()).resolve()
282
+
283
+
284
+ def _merge_base(repo_root: Path, base_oid: str, commit_sha: str, *, base_ref: str | None) -> str:
285
+ """The merge base of ``base_oid`` and ``commit_sha`` — the branch point.
286
+
287
+ Diffing the raw ``base..HEAD`` range renders every commit that landed on
288
+ the base but not on our branch as a DELETION in our diff. Reviewers are
289
+ then asked to justify removals nobody made, and the producer is handed
290
+ those phantom deletions as work. That is not a corner case: it is the
291
+ default whenever a branch is behind its base, which is most branches most
292
+ of the time. Diffing from the branch point instead is what every code
293
+ review tool means by "the diff", and what the operator means by "review my
294
+ branch".
295
+ """
296
+ rc, stdout, stderr = _git(repo_root, "merge-base", base_oid, commit_sha)
297
+ if rc != 0:
298
+ raise SnapshotError(
299
+ f"base_ref {base_ref!r} ({base_oid[:12]}) and HEAD ({commit_sha[:12]}) have no "
300
+ f"common ancestor in {repo_root}, so there is no branch point to review from: "
301
+ f"{stderr.strip() or 'no merge base'}. Pass --two-dot to diff the literal range "
302
+ f"instead, or supply a --base that shares history with HEAD."
303
+ )
304
+ merge_base = stdout.strip()
305
+ if not is_full_git_object_id(merge_base):
306
+ raise SnapshotError(
307
+ f"merge-base of {base_ref!r} and HEAD returned unexpected value "
308
+ f"{merge_base!r} (expected a full SHA-1/SHA-256 object ID)"
309
+ )
310
+ return merge_base
311
+
312
+
313
+ def take_snapshot(
314
+ repo_root: Path, *, base_ref: str | None = None, three_dot: bool = True
315
+ ) -> Snapshot:
316
+ """Capture a :class:`Snapshot` of ``repo_root`` at HEAD.
317
+
318
+ Args:
319
+ repo_root: Path to the repo to snapshot. Must be an existing
320
+ directory and a git working tree.
321
+ base_ref: Optional ref the diff is rendered against (e.g.
322
+ ``"main"``, ``"HEAD~3"``, a tag, a commit SHA). When
323
+ ``None`` (the default), no diff is captured — the
324
+ ``diff_text`` field is the empty string and the reviewer
325
+ prompt will use a "no diff provided" sentinel instead.
326
+ three_dot: When ``True`` (the default), diff from the BRANCH POINT
327
+ — the merge base of ``base_ref`` and HEAD — so commits that
328
+ landed on the base but not on this branch are not rendered as
329
+ phantom deletions. ``Snapshot.base_oid`` then holds that branch
330
+ point, i.e. it always names the commit the diff was actually
331
+ taken against. Pass ``False`` for the literal ``base..HEAD``
332
+ range (the ``--two-dot`` escape hatch), or when ``base_ref`` is
333
+ ALREADY a resolved effective base — a later round or a resume
334
+ re-snapshotting against a pinned ``base_oid`` — where recomputing
335
+ a merge base would be redundant.
336
+
337
+ Returns:
338
+ A :class:`Snapshot` populated with the resolved HEAD SHA,
339
+ branch name (or ``None`` for detached HEAD), the supplied
340
+ ``base_ref`` (or ``None``), and either the full diff text or
341
+ the empty string.
342
+
343
+ Raises:
344
+ SnapshotError: If ``repo_root`` isn't a git working tree, HEAD
345
+ is unresolvable (empty repo), ``base_ref`` doesn't resolve,
346
+ or git itself isn't installed. The message includes the
347
+ underlying git stderr where available.
348
+
349
+ The snapshot is a value object — no mutable state, no lazy
350
+ evaluation. The orchestrator takes it once at the top of a run and
351
+ passes it to everything else.
352
+
353
+ **Dirty working tree:** This does NOT refuse to run on a dirty
354
+ working tree. The reviewers see whatever's at HEAD; uncommitted
355
+ changes are invisible. A user who runs ``syncade`` against a
356
+ half-committed branch will not see their unstaged work reviewed,
357
+ by design. Refusing dirty trees would couple this library module
358
+ to a UX decision better made at the CLI surface.
359
+
360
+ The dirty signal is the four-state :data:`DirtyState` classification on
361
+ :attr:`Snapshot.dirty_state`, distinguishing tracked-modified
362
+ (the actually-dangerous case —
363
+ operator has local code changes the reviewers cannot see) from
364
+ untracked-only (usually intentional — operator has scratch
365
+ files they keep out of git on purpose). The orchestrator
366
+ branches on ``dirty_state`` to emit a strong warning vs. a soft
367
+ note vs. both vs. silence.
368
+ """
369
+ # repo_root must exist and be a directory before we even ask git
370
+ # anything. The downstream "not a git repository" error from git
371
+ # is fine but less actionable than naming the bad path here.
372
+ if not repo_root.exists():
373
+ raise SnapshotError(f"repo_root does not exist: {repo_root}")
374
+ if not repo_root.is_dir():
375
+ raise SnapshotError(f"repo_root is not a directory: {repo_root}")
376
+
377
+ # HEAD SHA — also doubles as the "is this a git repo?" probe.
378
+ # `--no-replace-objects` is belt-and-braces here, NOT the defense: measured,
379
+ # `rev-parse HEAD` is unaffected by refs/replace, because it resolves a ref
380
+ # NAME to a SHA without ever reading the object. The commands that ARE
381
+ # poisonable read commits (`log`, `merge-base`, `reset`, `diff`); they are
382
+ # covered structurally by `GIT_NO_REPLACE_OBJECTS=1` in
383
+ # `syncade.process.run_subprocess`, which every git call here routes through.
384
+ rc, sha_stdout, sha_stderr = _git(repo_root, "--no-replace-objects", "rev-parse", "HEAD")
385
+ if rc != 0:
386
+ # `git rev-parse HEAD` in a non-repo emits:
387
+ # "fatal: not a git repository (or any of the parent ...)".
388
+ # In an empty repo: "fatal: ambiguous argument 'HEAD' ..."
389
+ # Surface git's own message so the user can disambiguate.
390
+ raise SnapshotError(f"could not resolve HEAD in {repo_root}: {sha_stderr.strip()}")
391
+ commit_sha = sha_stdout.strip()
392
+ if not is_full_git_object_id(commit_sha):
393
+ raise SnapshotError(
394
+ f"git rev-parse HEAD returned unexpected value {commit_sha!r} "
395
+ f"(expected a full SHA-1/SHA-256 object ID)"
396
+ )
397
+
398
+ # Branch name. `--abbrev-ref HEAD` returns the branch name OR the
399
+ # literal "HEAD" when detached.
400
+ rc, branch_stdout, branch_stderr = _git(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
401
+ if rc != 0:
402
+ # Extremely unlikely once HEAD resolves, but surface it cleanly.
403
+ raise SnapshotError(
404
+ f"could not resolve branch name in {repo_root}: {branch_stderr.strip()}"
405
+ )
406
+ branch_raw = branch_stdout.strip()
407
+ branch: str | None = None if branch_raw == "HEAD" else branch_raw
408
+
409
+ # Diff capture — only when base_ref was supplied. An empty
410
+ # base_ref string is treated as "not supplied" to avoid the
411
+ # ambiguous case where the CLI's `--base ""` would otherwise pass
412
+ # through and confuse git.
413
+ diff_text = ""
414
+ if base_ref:
415
+ # Resolve the base to a full OID and diff THAT against the HEAD OID
416
+ # captured above — never the symbolic refs. `^{commit}` peels an
417
+ # annotated tag, which `git diff` would have done implicitly anyway.
418
+ #
419
+ # Diffing `<base_ref>..HEAD` re-resolved both ends at diff time, so a
420
+ # commit landing between the HEAD capture and this call produced a diff
421
+ # describing a DIFFERENT commit than `Snapshot.commit_sha` — reproduced
422
+ # against 6bb2890. The producer commits to this repo, so that race is
423
+ # ordinary operation, not a thought experiment.
424
+ # Two steps, not one. Appending `^{commit}` to the raw ref breaks git's
425
+ # own `:/<text>` commit-message search, which consumes the rest of the
426
+ # string as a regex and would hunt for the literal `<text>^{commit}` —
427
+ # a base that worked before this change and stopped working, caught by
428
+ # adversarial review. Resolve the ref first, then peel the OID.
429
+ rc, ref_oid_stdout, ref_stderr = _git(
430
+ repo_root, "--no-replace-objects", "rev-parse", "--verify", "--quiet", base_ref
431
+ )
432
+ if rc != 0:
433
+ raise SnapshotError(
434
+ f"base_ref {base_ref!r} does not resolve in {repo_root}: "
435
+ f"{ref_stderr.strip() or 'unknown ref'}"
436
+ )
437
+ rc, base_oid_stdout, peel_stderr = _git(
438
+ repo_root,
439
+ "--no-replace-objects",
440
+ "rev-parse",
441
+ "--verify",
442
+ "--quiet",
443
+ f"{ref_oid_stdout.strip()}^{{commit}}",
444
+ )
445
+ if rc != 0:
446
+ raise SnapshotError(
447
+ f"base_ref {base_ref!r} does not name a commit in {repo_root}: "
448
+ f"{peel_stderr.strip() or 'not peelable to a commit'}"
449
+ )
450
+ base_oid = base_oid_stdout.strip()
451
+ if not is_full_git_object_id(base_oid):
452
+ raise SnapshotError(
453
+ f"resolving base_ref {base_ref!r} returned unexpected value "
454
+ f"{base_oid!r} (expected a full SHA-1/SHA-256 object ID)"
455
+ )
456
+ if three_dot:
457
+ base_oid = _merge_base(repo_root, base_oid, commit_sha, base_ref=base_ref)
458
+ # On large repos this can be several seconds; the _GIT_TIMEOUT
459
+ # ceiling above handles runaway cases.
460
+ rc, diff_stdout, diff_stderr = _git(
461
+ repo_root, *_NORMALIZED_DIFF_ARGS, f"{base_oid}..{commit_sha}"
462
+ )
463
+ if rc != 0:
464
+ raise SnapshotError(
465
+ f"git diff {base_oid}..{commit_sha} failed in {repo_root} "
466
+ f"(base_ref {base_ref!r}): {diff_stderr.strip()}"
467
+ )
468
+ # `--text` forces git to emit raw binary content as text, which can include NUL
469
+ # bytes. Those NULs used to be stripped HERE, because the prompt was passed as an
470
+ # argv element and Python's subprocess rejects NUL in argv. PR-h-field-01 item 1 moved
471
+ # the prompt to stdin, which removed that constraint — and item 2 needs the NULs,
472
+ # because a NUL byte is git's own binary heuristic and the only binary signal an
473
+ # attacker cannot forge with a `.gitattributes` `-diff` entry. Stripping them here
474
+ # silently blinded that detection (measured: 6,667 NULs removed, every one of the
475
+ # 12 committed PNGs then read as text). `diff_filter.elide_binary_hunks` removes
476
+ # binary content — NULs included — at prompt assembly, after detection.
477
+ diff_text = _strip_hunk_function_context(diff_stdout)
478
+
479
+ # Working-tree cleanliness probe. `git status --porcelain` returns
480
+ # a stable, machine-parseable list (one line per affected path)
481
+ # with empty stdout on a clean tree. Gitignored paths are
482
+ # excluded by default — the user's `node_modules/` shouldn't
483
+ # make every snapshot dirty.
484
+ #
485
+ # classify by line prefix rather than a flat "any output
486
+ # = dirty" boolean. `??` prefix means untracked; everything else
487
+ # (" M", "M ", "MM", "A ", "D ", "R ", "C ", etc.) means
488
+ # tracked-modified or staged. The two cases have different
489
+ # operator-fix paths.
490
+ # `--no-replace-objects` prevents a replace ref on HEAD from making git
491
+ # compare the working tree to the replacement's tree instead of the real
492
+ # HEAD tree, which would produce a false "tracked-modified" dirty state.
493
+ rc, status_stdout, status_stderr = _git(
494
+ repo_root, "--no-replace-objects", "status", "--porcelain"
495
+ )
496
+ if rc != 0:
497
+ raise SnapshotError(
498
+ f"could not check working-tree state in {repo_root}: {status_stderr.strip()}"
499
+ )
500
+ dirty_state, untracked_count = _classify_porcelain_with_counts(status_stdout)
501
+
502
+ return Snapshot(
503
+ repo_root=repo_root.resolve(),
504
+ commit_sha=commit_sha,
505
+ branch=branch,
506
+ base_ref=base_ref,
507
+ base_oid=base_oid if base_ref else None,
508
+ diff_text=diff_text,
509
+ dirty_state=dirty_state,
510
+ untracked_count=untracked_count,
511
+ )
512
+
513
+
514
+ def _classify_porcelain(porcelain_output: str) -> DirtyState:
515
+ """Classify ``git status --porcelain`` output into a :data:`DirtyState`.
516
+
517
+ Parses line-by-line. A line starts with ``??`` iff
518
+ ``line[:2] == "??"``. Anything else with non-empty content is
519
+ tracked-modified or staged. Empty output → ``"clean"``.
520
+
521
+ Examples of tracked codes that should map to ``"tracked"``:
522
+ ``" M file.txt"`` (modified, not staged), ``"M file.txt"``
523
+ (staged modification), ``"MM file.txt"`` (staged + further
524
+ modified), ``"A file.txt"`` (added), ``"D file.txt"``
525
+ (deleted), ``"R old -> new"`` (renamed), ``"C old -> new"``
526
+ (copied).
527
+
528
+ Examples of untracked codes that should map to ``"untracked"``:
529
+ ``"?? scratch.txt"``, ``"?? path/with spaces.txt"``,
530
+ ``"?? .file-with-leading-dot"``.
531
+
532
+ Empty / whitespace-only output → ``"clean"`` even though the
533
+ function is called only when ``git status`` returned 0 — git's
534
+ own output may have trailing newlines we need to ignore.
535
+ """
536
+ stripped = porcelain_output.strip()
537
+ if not stripped:
538
+ return "clean"
539
+
540
+ has_tracked = False
541
+ has_untracked = False
542
+ for line in porcelain_output.splitlines():
543
+ if not line.strip():
544
+ # Defensive: blank line in the middle of git output is
545
+ # extremely unlikely but should not vote either way.
546
+ continue
547
+ if line[:2] == "??":
548
+ has_untracked = True
549
+ else:
550
+ # All other two-character prefixes encode tracked-file
551
+ # state. "Anything but ??" is the safe rule — new git
552
+ # versions may introduce additional codes (e.g. for new
553
+ # merge conflict states) and the strong-warning bias
554
+ # is the correct one for unfamiliar codes.
555
+ has_tracked = True
556
+
557
+ if has_tracked and has_untracked:
558
+ return "both"
559
+ if has_tracked:
560
+ return "tracked"
561
+ return "untracked"
562
+
563
+
564
+ def _classify_porcelain_with_counts(porcelain_output: str) -> tuple[DirtyState, int]:
565
+ """Classify porcelain output and count untracked files.
566
+
567
+ Counting happens here (once at snapshot time) rather than at
568
+ warning-emit time, so the orchestrator's soft note can include
569
+ ``<count> file(s)`` without re-running ``git status``. ``0``
570
+ for the clean and tracked-only states (no untracked files
571
+ even possible in those).
572
+
573
+ Returns ``(dirty_state, untracked_count)``. The single-pass
574
+ parser keeps the two values in lockstep — a future update to
575
+ the state-detection rule that adds new untracked codes would
576
+ need to update both classifications atomically.
577
+ """
578
+ stripped = porcelain_output.strip()
579
+ if not stripped:
580
+ return ("clean", 0)
581
+
582
+ has_tracked = False
583
+ has_untracked = False
584
+ untracked_count = 0
585
+ for line in porcelain_output.splitlines():
586
+ if not line.strip():
587
+ continue
588
+ if line[:2] == "??":
589
+ has_untracked = True
590
+ untracked_count += 1
591
+ else:
592
+ has_tracked = True
593
+
594
+ if has_tracked and has_untracked:
595
+ return ("both", untracked_count)
596
+ if has_tracked:
597
+ return ("tracked", untracked_count)
598
+ return ("untracked", untracked_count)