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/worktree.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Worktree management for blind reviewer dispatch.
|
|
2
|
+
|
|
3
|
+
Provides the primitives that put each reviewer in its own physically isolated
|
|
4
|
+
git worktree: the error type, default base path, run-id helper, and
|
|
5
|
+
``WorktreeManager``.
|
|
6
|
+
|
|
7
|
+
This module is intentionally decoupled from :mod:`syncade.config`.
|
|
8
|
+
Callers pull values out of :class:`syncade.config.SyncadeConfig`
|
|
9
|
+
themselves and pass them in as plain types, which keeps the worktree
|
|
10
|
+
code narrowly scoped and trivially testable without a config object.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from types import TracebackType
|
|
22
|
+
|
|
23
|
+
from syncade.process import SubprocessError, SubprocessResult, run_subprocess
|
|
24
|
+
|
|
25
|
+
from .worktree_paths import (
|
|
26
|
+
WorktreeError,
|
|
27
|
+
_registered_path_differs_from_actual,
|
|
28
|
+
_strip_files,
|
|
29
|
+
_validate_reviewer_name,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DEFAULT_WORKTREE_BASE: Path = Path("/tmp/syncade")
|
|
33
|
+
"""Filesystem location under which all per-run worktrees are created.
|
|
34
|
+
|
|
35
|
+
Configurable via :class:`WorktreeManager`'s constructor argument; the
|
|
36
|
+
default keeps worktrees out of the user's repo and on a tmpfs where the
|
|
37
|
+
OS will clear them on reboot.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
TEST_WORKTREE_NAME: str = "tests"
|
|
42
|
+
"""Reserved reviewer-name basename used by the test re-run leg.
|
|
43
|
+
|
|
44
|
+
Single source of truth — the orchestrator uses it for
|
|
45
|
+
the actual provisioning, and :mod:`syncade.config` uses it (via
|
|
46
|
+
``casefold()`` comparison) to reject reviewer configs
|
|
47
|
+
that would collide with it when ``[loop] test_command`` is set.
|
|
48
|
+
|
|
49
|
+
Keeping the name in this module gives the orchestrator and config
|
|
50
|
+
validator a neutral import point without creating an import cycle:
|
|
51
|
+
config → worktree is one-way; worktree imports no syncade modules."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_PREFIX_DISALLOWED = re.compile(r"[^A-Za-z0-9_-]")
|
|
55
|
+
"""Inverse character class — anything that is *not* an ASCII
|
|
56
|
+
alphanumeric, hyphen, or underscore. Used to strip junk from prefixes."""
|
|
57
|
+
|
|
58
|
+
_GIT_TIMEOUT_SECONDS = 30.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_git(argv: list[str], *, cwd: Path, error_prefix: str) -> SubprocessResult:
|
|
62
|
+
try:
|
|
63
|
+
return run_subprocess(argv, cwd=cwd, timeout=_GIT_TIMEOUT_SECONDS)
|
|
64
|
+
except SubprocessError as exc:
|
|
65
|
+
raise WorktreeError(f"{error_prefix}: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run_git_best_effort(argv: list[str], *, cwd: Path) -> None:
|
|
69
|
+
try:
|
|
70
|
+
run_subprocess(argv, cwd=cwd, timeout=_GIT_TIMEOUT_SECONDS)
|
|
71
|
+
except SubprocessError:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def generate_run_id(prefix: str | None = None) -> str:
|
|
76
|
+
"""Return a stable, human-readable run identifier.
|
|
77
|
+
|
|
78
|
+
Format: ``YYYY-MM-DDTHH-MM-SS[-prefix]`` (e.g. ``2026-05-11T17-23-04``
|
|
79
|
+
or ``2026-05-11T17-23-04-pr2`` if a prefix is supplied). The
|
|
80
|
+
colon-free form is intentional so the id can be used as a filesystem
|
|
81
|
+
path component on all platforms without quoting.
|
|
82
|
+
|
|
83
|
+
The prefix is sanitized in two passes: first, only ASCII
|
|
84
|
+
alphanumerics, hyphens, and underscores are kept; everything else
|
|
85
|
+
is stripped. Then leading and trailing hyphens and underscores are
|
|
86
|
+
trimmed so the joined id reads cleanly (no ``...-T--leading`` or
|
|
87
|
+
``...-trailing-`` shapes). A prefix that is empty after both
|
|
88
|
+
passes (all junk, or only separator characters) yields a bare
|
|
89
|
+
timestamp with no suffix. An empty string is treated the same as
|
|
90
|
+
``None``.
|
|
91
|
+
|
|
92
|
+
Resolution is one second. Two calls inside the same wall-clock
|
|
93
|
+
second produce identical ids — callers that need uniqueness across
|
|
94
|
+
rapid invocations must pass distinct prefixes.
|
|
95
|
+
"""
|
|
96
|
+
timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
97
|
+
if not prefix:
|
|
98
|
+
return timestamp
|
|
99
|
+
cleaned = _PREFIX_DISALLOWED.sub("", prefix).strip("-_")
|
|
100
|
+
if not cleaned:
|
|
101
|
+
return timestamp
|
|
102
|
+
return f"{timestamp}-{cleaned}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Worktree:
|
|
107
|
+
"""A single managed worktree.
|
|
108
|
+
|
|
109
|
+
Immutable record produced by :meth:`WorktreeManager.create`; the
|
|
110
|
+
manager owns lifecycle (creation and cleanup).
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
path: Absolute path to the worktree on disk.
|
|
114
|
+
reviewer_name: Identifier passed at creation time. Used as the
|
|
115
|
+
directory name under ``run_dir``.
|
|
116
|
+
commit_sha: Full object ID the worktree is checked out
|
|
117
|
+
at. The :meth:`WorktreeManager.create` method canonicalizes whatever
|
|
118
|
+
commit-ish the caller supplied (short SHA, branch name,
|
|
119
|
+
tag, ...) to the underlying commit, so this field is
|
|
120
|
+
always the authoritative identifier.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
path: Path
|
|
124
|
+
reviewer_name: str
|
|
125
|
+
commit_sha: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class WorktreeManager:
|
|
129
|
+
"""Provisions and cleans up per-reviewer git worktrees for a run.
|
|
130
|
+
|
|
131
|
+
Use as a context manager — on clean exit, all worktrees created
|
|
132
|
+
through this manager are removed. On exception, worktrees are
|
|
133
|
+
intentionally left in place so the user can inspect what each
|
|
134
|
+
reviewer saw.
|
|
135
|
+
|
|
136
|
+
Example::
|
|
137
|
+
|
|
138
|
+
run_id = generate_run_id("pr-3")
|
|
139
|
+
with WorktreeManager(repo_root, run_id) as mgr:
|
|
140
|
+
wt = mgr.create(
|
|
141
|
+
"claude-reviewer",
|
|
142
|
+
commit_sha,
|
|
143
|
+
strip_files=["CLAUDE.md", "AGENTS.md"],
|
|
144
|
+
)
|
|
145
|
+
# dispatch reviewer subprocess into wt.path ...
|
|
146
|
+
# cleanup happens automatically on context exit
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
repo_root: Path,
|
|
152
|
+
run_id: str,
|
|
153
|
+
base_dir: Path = DEFAULT_WORKTREE_BASE,
|
|
154
|
+
*,
|
|
155
|
+
defer_cleanup: bool = False,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Construct the manager. No filesystem side effects yet.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
repo_root: The git repo whose history we're worktreeing.
|
|
161
|
+
run_id: Identifier for this run (use :func:`generate_run_id`).
|
|
162
|
+
base_dir: Where to place worktrees on disk. Defaults to
|
|
163
|
+
:data:`DEFAULT_WORKTREE_BASE`.
|
|
164
|
+
defer_cleanup: When ``True``, the context
|
|
165
|
+
manager's ``__exit__`` does NOT auto-cleanup on
|
|
166
|
+
clean exit. The caller (orchestrator) is responsible
|
|
167
|
+
for calling :meth:`cleanup_all` explicitly later.
|
|
168
|
+
Used by the loop orchestrator to defer the
|
|
169
|
+
cleanup-vs-preserve decision until the loop's final
|
|
170
|
+
exit code is known: the PRD specifies that exits
|
|
171
|
+
10/20/30 keep worktrees for inspection.
|
|
172
|
+
Default ``False`` auto-cleans on clean exit.
|
|
173
|
+
"""
|
|
174
|
+
self._repo_root = Path(repo_root)
|
|
175
|
+
self._run_id = run_id
|
|
176
|
+
self._base_dir = Path(base_dir)
|
|
177
|
+
self._worktrees: list[Worktree] = []
|
|
178
|
+
self._defer_cleanup = defer_cleanup
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def run_dir(self) -> Path:
|
|
182
|
+
"""``base_dir / run_id`` — the parent directory containing all
|
|
183
|
+
worktrees for this run."""
|
|
184
|
+
return self._base_dir / self._run_id
|
|
185
|
+
|
|
186
|
+
def create(
|
|
187
|
+
self,
|
|
188
|
+
reviewer_name: str,
|
|
189
|
+
commit_sha: str,
|
|
190
|
+
strip_files: list[str] | None = None,
|
|
191
|
+
) -> Worktree:
|
|
192
|
+
"""Create a worktree at ``run_dir / reviewer_name`` checked out
|
|
193
|
+
at ``commit_sha``.
|
|
194
|
+
|
|
195
|
+
``reviewer_name`` must be a plain basename — empty strings,
|
|
196
|
+
``"."``, ``".."``, names containing a path separator, and
|
|
197
|
+
absolute paths are rejected before any filesystem or git side
|
|
198
|
+
effects so a malformed name cannot make the worktree escape
|
|
199
|
+
:attr:`run_dir`.
|
|
200
|
+
|
|
201
|
+
If ``strip_files`` is provided, those filenames (matched by
|
|
202
|
+
basename recursively) are deleted after worktree creation;
|
|
203
|
+
missing files are not an error. Returns the resulting
|
|
204
|
+
:class:`Worktree` record.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
WorktreeError: On invalid ``reviewer_name``, on
|
|
208
|
+
``git worktree add`` failure (message includes git
|
|
209
|
+
stderr), or if the target worktree directory already
|
|
210
|
+
exists.
|
|
211
|
+
"""
|
|
212
|
+
_validate_reviewer_name(reviewer_name)
|
|
213
|
+
target = (self.run_dir / reviewer_name).absolute()
|
|
214
|
+
if target.exists():
|
|
215
|
+
raise WorktreeError(
|
|
216
|
+
f"git worktree add failed: target directory already exists: {target}"
|
|
217
|
+
)
|
|
218
|
+
# `git worktree add` requires the parent of the target to exist
|
|
219
|
+
# but does not create intermediate directories itself. If
|
|
220
|
+
# `run_dir` already exists as a (non-directory) file, mkdir
|
|
221
|
+
# raises FileExistsError; surface that as a typed
|
|
222
|
+
# provisioning error rather than a raw OSError so the CLI can
|
|
223
|
+
# map it to exit code 60.
|
|
224
|
+
try:
|
|
225
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
raise WorktreeError(
|
|
228
|
+
f"git worktree add failed: could not create run_dir {self.run_dir}: {exc}"
|
|
229
|
+
) from exc
|
|
230
|
+
# `refs/replace/*` lives in the shared common dir and is writable from a
|
|
231
|
+
# producer worktree. Without --no-replace-objects, `git worktree add <sha>`
|
|
232
|
+
# checks out the replacement object while `Snapshot.commit_sha` names the
|
|
233
|
+
# original — a backdoored commit reviewed as its benign replacement.
|
|
234
|
+
result = _run_git(
|
|
235
|
+
["git", "--no-replace-objects", "worktree", "add", str(target), commit_sha],
|
|
236
|
+
cwd=self._repo_root,
|
|
237
|
+
error_prefix="git worktree add failed",
|
|
238
|
+
)
|
|
239
|
+
if result.returncode != 0:
|
|
240
|
+
raise WorktreeError(f"git worktree add failed: {result.stderr.strip()}")
|
|
241
|
+
# `git worktree add` succeeded — from here until we append to
|
|
242
|
+
# self._worktrees, any failure must roll back the worktree so
|
|
243
|
+
# we don't orphan the directory or the git registration. The
|
|
244
|
+
# try / except BaseException covers normal exceptions plus
|
|
245
|
+
# KeyboardInterrupt and SystemExit so a poorly-timed Ctrl-C
|
|
246
|
+
# also leaves the manager's invariants intact.
|
|
247
|
+
try:
|
|
248
|
+
# Canonicalize commit_sha to the full object ID. Caller
|
|
249
|
+
# may have passed a short SHA, branch name, or tag —
|
|
250
|
+
# `git rev-parse HEAD` inside the new worktree is the
|
|
251
|
+
# authoritative source.
|
|
252
|
+
rev_parse = _run_git(
|
|
253
|
+
["git", "rev-parse", "HEAD"],
|
|
254
|
+
cwd=target,
|
|
255
|
+
error_prefix="git rev-parse HEAD failed",
|
|
256
|
+
)
|
|
257
|
+
if rev_parse.returncode != 0:
|
|
258
|
+
raise WorktreeError(f"git rev-parse HEAD failed: {rev_parse.stderr.strip()}")
|
|
259
|
+
worktree = Worktree(
|
|
260
|
+
path=target,
|
|
261
|
+
reviewer_name=reviewer_name,
|
|
262
|
+
commit_sha=rev_parse.stdout.strip(),
|
|
263
|
+
)
|
|
264
|
+
if strip_files:
|
|
265
|
+
_strip_files(target, strip_files)
|
|
266
|
+
self._worktrees.append(worktree)
|
|
267
|
+
return worktree
|
|
268
|
+
except BaseException:
|
|
269
|
+
self._rollback_add(target)
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
def _rollback_add(self, target: Path) -> None:
|
|
273
|
+
"""Undo a partially-completed :meth:`create` after
|
|
274
|
+
``git worktree add`` has already succeeded but a later step
|
|
275
|
+
failed. Best-effort — runs ``git worktree remove --force`` and
|
|
276
|
+
``shutil.rmtree`` and swallows everything, because we're
|
|
277
|
+
already in the middle of unwinding the caller's exception and
|
|
278
|
+
masking it would be worse than leaving stray state.
|
|
279
|
+
"""
|
|
280
|
+
_run_git_best_effort(
|
|
281
|
+
["git", "worktree", "remove", "--force", str(target)],
|
|
282
|
+
cwd=self._repo_root,
|
|
283
|
+
)
|
|
284
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
285
|
+
|
|
286
|
+
def cleanup(self, worktree: Worktree) -> None:
|
|
287
|
+
"""Remove a single worktree.
|
|
288
|
+
|
|
289
|
+
Runs ``git worktree remove --force`` on the path, then
|
|
290
|
+
``shutil.rmtree`` as a belt-and-braces filesystem clean,
|
|
291
|
+
then ``git worktree prune`` to drop any stale
|
|
292
|
+
``.git/worktrees/<name>/`` metadata git might still hold.
|
|
293
|
+
|
|
294
|
+
Safe to call multiple times: if the worktree path no
|
|
295
|
+
longer exists when called, runs ``git worktree prune``
|
|
296
|
+
anyway (to clear stale metadata from a prior
|
|
297
|
+
non-git-mediated deletion) and returns. If the path
|
|
298
|
+
still exists but ``git worktree remove`` fails, the
|
|
299
|
+
recovery path detects stale-metadata drift by comparing the
|
|
300
|
+
registered gitdir-file path to the actual path. This is
|
|
301
|
+
locale-independent and avoids English-substring matching
|
|
302
|
+
against git's stderr. The recovery path either succeeds via a
|
|
303
|
+
prune-then-rmtree fallback or raises
|
|
304
|
+
:class:`WorktreeError` with the diagnostics.
|
|
305
|
+
|
|
306
|
+
The recovery path is structured around three goals:
|
|
307
|
+
|
|
308
|
+
1. Leave git registration AND filesystem in a consistent
|
|
309
|
+
state — never end in "git no longer recognizes the
|
|
310
|
+
path, but the directory still exists".
|
|
311
|
+
2. Tolerate metadata drift from external rm / symlink-
|
|
312
|
+
resolution changes / interrupted prior cleanups
|
|
313
|
+
without surfacing English-substring matching to the
|
|
314
|
+
caller.
|
|
315
|
+
3. Stay best-effort idempotent: cleanup of an already-gone
|
|
316
|
+
worktree never raises; the only raise path is a hard
|
|
317
|
+
failure where the directory genuinely still exists on
|
|
318
|
+
disk AND git failed to remove it AND our fallback
|
|
319
|
+
rmtree also failed.
|
|
320
|
+
|
|
321
|
+
:meth:`__exit__` catches and warns on any such failure so
|
|
322
|
+
an original in-flight exception is never masked.
|
|
323
|
+
"""
|
|
324
|
+
if not worktree.path.exists():
|
|
325
|
+
# Path is gone but git's metadata might still point at
|
|
326
|
+
# it. Prune now so a future provisioning attempt with
|
|
327
|
+
# the same name doesn't hit the stale-metadata
|
|
328
|
+
# validation error.
|
|
329
|
+
self._git_worktree_prune()
|
|
330
|
+
return
|
|
331
|
+
result = _run_git(
|
|
332
|
+
["git", "worktree", "remove", "--force", str(worktree.path)],
|
|
333
|
+
cwd=self._repo_root,
|
|
334
|
+
error_prefix="git worktree remove failed",
|
|
335
|
+
)
|
|
336
|
+
if result.returncode == 0:
|
|
337
|
+
shutil.rmtree(worktree.path, ignore_errors=True)
|
|
338
|
+
# Prune is cheap and idempotent; run it even on the
|
|
339
|
+
# happy path in case the remove succeeded but left
|
|
340
|
+
# metadata fragments (defensive — git's own remove
|
|
341
|
+
# generally cleans this up, but edge cases exist).
|
|
342
|
+
self._git_worktree_prune()
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
# remove failed. If the path disappeared mid-call (a
|
|
346
|
+
# concurrent actor), that's still idempotent "nothing to
|
|
347
|
+
# do" — prune any partial metadata git may have left and
|
|
348
|
+
# exit cleanly.
|
|
349
|
+
if not worktree.path.exists():
|
|
350
|
+
self._git_worktree_prune()
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# remove failed AND the path still exists. Detect the
|
|
354
|
+
# stale-metadata-drift case structurally: the
|
|
355
|
+
# registered ``.git/worktrees/<name>/gitdir`` file
|
|
356
|
+
# records the worktree path git thinks the registration
|
|
357
|
+
# points at. If it disagrees with the actual on-disk
|
|
358
|
+
# path (symlink-resolution drift, prior partial cleanup),
|
|
359
|
+
# prune + try again. Even if prune + retry fails, fall
|
|
360
|
+
# through to rmtree the on-disk directory — at that
|
|
361
|
+
# point git has dropped its registration so rmtree is
|
|
362
|
+
# safe and doesn't leave inconsistent state.
|
|
363
|
+
original_stderr = result.stderr.strip()
|
|
364
|
+
if _registered_path_differs_from_actual(self._repo_root, worktree):
|
|
365
|
+
self._git_worktree_prune()
|
|
366
|
+
retry = _run_git(
|
|
367
|
+
["git", "worktree", "remove", "--force", str(worktree.path)],
|
|
368
|
+
cwd=self._repo_root,
|
|
369
|
+
error_prefix="git worktree remove failed",
|
|
370
|
+
)
|
|
371
|
+
if retry.returncode == 0:
|
|
372
|
+
shutil.rmtree(worktree.path, ignore_errors=True)
|
|
373
|
+
self._git_worktree_prune()
|
|
374
|
+
return
|
|
375
|
+
# prune cleared git's registration, but the
|
|
376
|
+
# remove retry failed (typically because git no
|
|
377
|
+
# longer recognizes the path as a worktree). Without
|
|
378
|
+
# this fallback the directory was left on disk →
|
|
379
|
+
# next worktree-add with the same name would hit
|
|
380
|
+
# "target directory already exists". rmtree the
|
|
381
|
+
# directory directly now that git has nothing
|
|
382
|
+
# registered to inconsistently affect.
|
|
383
|
+
try:
|
|
384
|
+
shutil.rmtree(worktree.path)
|
|
385
|
+
except OSError:
|
|
386
|
+
# rmtree failed too — at this point we genuinely
|
|
387
|
+
# can't clean up. Raise with the original git
|
|
388
|
+
# diagnostic so the operator sees the first cause.
|
|
389
|
+
raise WorktreeError(
|
|
390
|
+
f"git worktree remove failed AND structural-recovery "
|
|
391
|
+
f"rmtree of {worktree.path} also failed: "
|
|
392
|
+
f"{original_stderr}"
|
|
393
|
+
) from None
|
|
394
|
+
self._git_worktree_prune()
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# Not a stale-metadata case — surface git's original
|
|
398
|
+
# diagnostic verbatim.
|
|
399
|
+
raise WorktreeError(f"git worktree remove failed: {original_stderr}")
|
|
400
|
+
|
|
401
|
+
def _git_worktree_prune(self) -> None:
|
|
402
|
+
"""Run ``git worktree prune`` to clean stale registered
|
|
403
|
+
worktree metadata. Best-effort: silently no-op on git
|
|
404
|
+
error (this is a cleanup path; we'd already be losing the
|
|
405
|
+
original failure context if we raised here).
|
|
406
|
+
|
|
407
|
+
Git's ``.git/worktrees/<name>/`` directories record the registered
|
|
408
|
+
worktree path. When the actual worktree directory is deleted
|
|
409
|
+
out-of-band (not via ``git worktree remove``), the metadata persists
|
|
410
|
+
and pollutes subsequent worktree-add attempts. ``git worktree prune``
|
|
411
|
+
is the canonical cleanup: it walks the metadata and drops any entry
|
|
412
|
+
whose ``gitdir`` file points at a nonexistent path.
|
|
413
|
+
"""
|
|
414
|
+
_run_git_best_effort(
|
|
415
|
+
["git", "worktree", "prune"],
|
|
416
|
+
cwd=self._repo_root,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def cleanup_all(self) -> None:
|
|
420
|
+
"""Remove every worktree created through this manager.
|
|
421
|
+
|
|
422
|
+
Each worktree is cleaned independently. A per-entry failure
|
|
423
|
+
(e.g. a git lock or NFS stall on ``git worktree remove``) is
|
|
424
|
+
warned to stderr and the loop continues, so one stuck worktree
|
|
425
|
+
can never orphan the rest. After every entry has been attempted
|
|
426
|
+
the accumulated failures are re-raised as a single
|
|
427
|
+
:class:`WorktreeError`, preserving the existing caller contract
|
|
428
|
+
(:meth:`__exit__` warns; ``loop_finalize`` swallows).
|
|
429
|
+
|
|
430
|
+
Also attempts to remove :attr:`run_dir` itself if it is empty
|
|
431
|
+
after cleanup. A non-empty ``run_dir`` is left alone — likely
|
|
432
|
+
another concurrent run, or stray reviewer scratch state worth
|
|
433
|
+
preserving for inspection.
|
|
434
|
+
"""
|
|
435
|
+
errors: list[str] = []
|
|
436
|
+
for worktree in self._worktrees:
|
|
437
|
+
try:
|
|
438
|
+
self.cleanup(worktree)
|
|
439
|
+
except WorktreeError as exc:
|
|
440
|
+
# Isolate the failure: warn and keep going so a single
|
|
441
|
+
# stuck entry never strands the worktrees behind it.
|
|
442
|
+
print(
|
|
443
|
+
f"[syncade] warning: failed to clean up worktree "
|
|
444
|
+
f"{worktree.reviewer_name!r} at {worktree.path}: {exc}",
|
|
445
|
+
file=sys.stderr,
|
|
446
|
+
)
|
|
447
|
+
errors.append(str(exc))
|
|
448
|
+
if self.run_dir.exists():
|
|
449
|
+
try:
|
|
450
|
+
self.run_dir.rmdir()
|
|
451
|
+
except OSError:
|
|
452
|
+
# Not empty (or otherwise un-removable) — leave it.
|
|
453
|
+
pass
|
|
454
|
+
if errors:
|
|
455
|
+
raise WorktreeError(
|
|
456
|
+
f"cleanup_all: {len(errors)} of {len(self._worktrees)} "
|
|
457
|
+
"worktree(s) failed to clean up: " + "; ".join(errors)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def __enter__(self) -> WorktreeManager:
|
|
461
|
+
"""Enter the context manager. No side effects beyond returning
|
|
462
|
+
self; worktrees are only created via explicit :meth:`create`
|
|
463
|
+
calls."""
|
|
464
|
+
return self
|
|
465
|
+
|
|
466
|
+
def __exit__(
|
|
467
|
+
self,
|
|
468
|
+
exc_type: type[BaseException] | None,
|
|
469
|
+
exc_val: BaseException | None,
|
|
470
|
+
exc_tb: TracebackType | None,
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Exit the context manager.
|
|
473
|
+
|
|
474
|
+
On clean exit (``exc_type is None``) AND ``defer_cleanup``
|
|
475
|
+
is ``False`` (the default), calls :meth:`cleanup_all`. On
|
|
476
|
+
exception exit, worktrees are intentionally preserved so
|
|
477
|
+
the user can inspect what each reviewer saw. When
|
|
478
|
+
``defer_cleanup=True``, cleanup is skipped on clean exit
|
|
479
|
+
too — the caller (orchestrator) calls
|
|
480
|
+
:meth:`cleanup_all` explicitly later based on the loop's
|
|
481
|
+
final exit code.
|
|
482
|
+
|
|
483
|
+
Either way, this method must not raise — failures during
|
|
484
|
+
cleanup are warned to stderr rather than propagated, so the
|
|
485
|
+
original exception is not masked.
|
|
486
|
+
"""
|
|
487
|
+
if exc_type is not None:
|
|
488
|
+
return
|
|
489
|
+
if self._defer_cleanup:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
self.cleanup_all()
|
|
493
|
+
except Exception as exc:
|
|
494
|
+
print(
|
|
495
|
+
f"[syncade] warning: worktree cleanup failed: {exc}",
|
|
496
|
+
file=sys.stderr,
|
|
497
|
+
)
|
syncade/worktree_env.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Worktree-scoped Python environment for subprocess legs.
|
|
2
|
+
|
|
3
|
+
**The bleed this closes.** ``pip install -e .`` drops an editable-install
|
|
4
|
+
``.pth`` (``__editable__.syncade-<version>.pth``) into the venv's site-packages
|
|
5
|
+
containing a bare path to the operator's MAIN repo ``src``. A bare-path ``.pth``
|
|
6
|
+
line is processed by :mod:`site` at interpreter startup and added to
|
|
7
|
+
``sys.path`` — **cwd-independent**. So every worktree subprocess (reviewers,
|
|
8
|
+
producer, the authoritative test leg, the checks leg) that inherits the
|
|
9
|
+
operator's environment resolves ``import syncade`` — and runs ``pytest`` — against
|
|
10
|
+
MAIN's ``src``, not the worktree's snapshot. That is a correctness hole (the test
|
|
11
|
+
leg certifies the wrong tree) and the cause of claude-reviewer's timeouts (it
|
|
12
|
+
burned ~20 min detecting and hand-working-around the wrong-src import).
|
|
13
|
+
|
|
14
|
+
**Isolation strategy.** The common editable install is the *plain-path* ``.pth``
|
|
15
|
+
variant: ``site`` appends MAIN's ``src`` after ``PYTHONPATH``, so putting
|
|
16
|
+
``<worktree>/src`` near the front makes the worktree resolve first. Setuptools
|
|
17
|
+
can also install a strict editable hook as a ``MetaPathFinder``; that hook beats
|
|
18
|
+
``sys.path`` and would still route ``syncade`` imports to MAIN. To cover both
|
|
19
|
+
forms, the child env prepends a tiny ``sitecustomize`` shim directory before
|
|
20
|
+
``<worktree>/src``. At interpreter startup the shim removes syncade-targeting
|
|
21
|
+
editable finders, then normal ``sys.path`` resolution imports the worktree copy.
|
|
22
|
+
|
|
23
|
+
This is verified empirically, not assumed:
|
|
24
|
+
:func:`tests.test_worktree_env.test_real_subprocess_imports_worktree_src_not_main`
|
|
25
|
+
launches a real subprocess in *this* venv (whose ``.pth`` is live) and asserts
|
|
26
|
+
``syncade.__file__`` resolves under the worktree, not MAIN. A companion test
|
|
27
|
+
injects a strict editable-style ``MetaPathFinder`` and verifies the startup shim
|
|
28
|
+
neutralizes it before the first ``syncade`` import.
|
|
29
|
+
|
|
30
|
+
One helper, all four legs — the two reviewer adapters, the two producer adapters,
|
|
31
|
+
``test_runner.run_tests`` — including the mechanical-check leg —
|
|
32
|
+
so no leg can drift back onto the inherited env.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import tempfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
_SHIM_DIRNAME = "syncade-worktree-env-shim-v1"
|
|
42
|
+
_SHIM_SOURCE = """\
|
|
43
|
+
\"\"\"Neutralize parent editable-install hooks for syncade worktree children.\"\"\"
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import sys
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _module_name(obj):
|
|
51
|
+
return getattr(obj, "__module__", None) or type(obj).__module__
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _finder_targets_syncade(finder) -> bool:
|
|
55
|
+
module_name = _module_name(finder)
|
|
56
|
+
module = sys.modules.get(module_name)
|
|
57
|
+
names = set()
|
|
58
|
+
if module is not None:
|
|
59
|
+
mapping = getattr(module, "MAPPING", {})
|
|
60
|
+
namespaces = getattr(module, "NAMESPACES", {})
|
|
61
|
+
if isinstance(mapping, dict):
|
|
62
|
+
names.update(str(name) for name in mapping)
|
|
63
|
+
if isinstance(namespaces, dict):
|
|
64
|
+
names.update(str(name) for name in namespaces)
|
|
65
|
+
return (
|
|
66
|
+
module_name.startswith("__editable___syncade")
|
|
67
|
+
or "syncade" in names
|
|
68
|
+
or any(name.startswith("syncade.") for name in names)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
sys.meta_path[:] = [finder for finder in sys.meta_path if not _finder_targets_syncade(finder)]
|
|
73
|
+
sys.path[:] = [path for path in sys.path if "__editable__.syncade" not in path]
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ensure_startup_shim() -> str:
|
|
78
|
+
shim_dir = Path(tempfile.gettempdir()) / _SHIM_DIRNAME
|
|
79
|
+
shim_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
shim_path = shim_dir / "sitecustomize.py"
|
|
81
|
+
if not shim_path.exists() or shim_path.read_text(encoding="utf-8") != _SHIM_SOURCE:
|
|
82
|
+
handle, tmp_name = tempfile.mkstemp(
|
|
83
|
+
prefix=f".sitecustomize.{os.getpid()}.",
|
|
84
|
+
suffix=".tmp",
|
|
85
|
+
dir=shim_dir,
|
|
86
|
+
text=True,
|
|
87
|
+
)
|
|
88
|
+
tmp_path = Path(tmp_name)
|
|
89
|
+
try:
|
|
90
|
+
with os.fdopen(handle, "w", encoding="utf-8") as tmp:
|
|
91
|
+
tmp.write(_SHIM_SOURCE)
|
|
92
|
+
os.replace(tmp_path, shim_path)
|
|
93
|
+
finally:
|
|
94
|
+
try:
|
|
95
|
+
tmp_path.unlink()
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
pass
|
|
98
|
+
return str(shim_dir)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def worktree_scoped_env(worktree_path: Path) -> dict[str, str]:
|
|
102
|
+
"""Build a child environment that resolves ``syncade`` to *this worktree's*
|
|
103
|
+
``src`` instead of the operator's MAIN repo.
|
|
104
|
+
|
|
105
|
+
Inherits the operator's environment verbatim (so keychain / OAuth /
|
|
106
|
+
``ANTHROPIC_API_KEY`` auth still flows through) and prepends a startup shim
|
|
107
|
+
plus ``<worktree_path>/src`` to ``PYTHONPATH`` — ahead of any pre-existing
|
|
108
|
+
``PYTHONPATH`` and the editable install's MAIN path (see module docstring).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
worktree_path: The git-worktree root the subprocess will run in. Its
|
|
112
|
+
``src`` need not yet exist; a non-existent ``PYTHONPATH`` entry is
|
|
113
|
+
harmlessly ignored by the child interpreter.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A new ``dict`` suitable for ``run_subprocess(env=...)`` / ``Invocation.env``.
|
|
117
|
+
The caller's own ``os.environ`` is never mutated.
|
|
118
|
+
"""
|
|
119
|
+
env = dict(os.environ)
|
|
120
|
+
shim_dir = _ensure_startup_shim()
|
|
121
|
+
worktree_src = str(worktree_path / "src")
|
|
122
|
+
existing = env.get("PYTHONPATH")
|
|
123
|
+
scoped_entries = [shim_dir, worktree_src]
|
|
124
|
+
if existing:
|
|
125
|
+
scoped_entries.append(existing)
|
|
126
|
+
env["PYTHONPATH"] = os.pathsep.join(scoped_entries)
|
|
127
|
+
# Any git command run inside a worktree subprocess must see the ORIGINAL
|
|
128
|
+
# object graph. `refs/replace/*` lives in the shared common dir and is
|
|
129
|
+
# writable from a producer worktree, so without this flag a reviewer doing
|
|
130
|
+
# `git show HEAD:f.py` or a test doing `git diff HEAD` would silently read
|
|
131
|
+
# replacement objects rather than the objects named by the snapshot OID.
|
|
132
|
+
env["GIT_NO_REPLACE_OBJECTS"] = "1"
|
|
133
|
+
return env
|