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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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)
|