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/gc_worktrees.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Worktree-tree selection for ``syncade --gc``.
|
|
2
|
+
|
|
3
|
+
Split from :mod:`syncade.gc` to keep both files under the blocking file-length
|
|
4
|
+
gate. Two concerns:
|
|
5
|
+
|
|
6
|
+
- :func:`existing_worktree_trees` — map run-ids being pruned to their on-disk
|
|
7
|
+
``/tmp/syncade/<run-id>/`` trees (those are provably this repo's: each maps to
|
|
8
|
+
a run dir under this ``.syncade/runs/``).
|
|
9
|
+
- :func:`repo_owned_orphan_trees` — ``/tmp/syncade/<run-id>/`` trees whose run
|
|
10
|
+
dir is GONE but which are STILL provably this repo's, because a live nested git
|
|
11
|
+
worktree strictly under them is registered in this repo's ``git worktree
|
|
12
|
+
list``. This is the ONLY safe way to clean a gone-run leftover off the shared
|
|
13
|
+
worktree base without risking another repo's tree: a foreign repo's tree is
|
|
14
|
+
not in our live worktree registry, so it is never matched. Stale registry
|
|
15
|
+
strings are not trusted: the registered path must still exist, share this
|
|
16
|
+
repo's git common dir, and live strictly under the candidate tree. Paths are
|
|
17
|
+
``resolve()``-d before comparison because git reports worktrees under
|
|
18
|
+
``/private/tmp`` on macOS while the base is ``/tmp`` (a symlink).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from syncade.process import SubprocessError, run_subprocess
|
|
26
|
+
|
|
27
|
+
_GIT_WORKTREE_LIST_TIMEOUT_SECONDS: float = 30.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def existing_worktree_trees(worktree_base: Path, run_ids: list[str]) -> list[Path]:
|
|
31
|
+
"""Map slimmable run-ids to their on-disk worktree trees (existing only)."""
|
|
32
|
+
trees: list[Path] = []
|
|
33
|
+
for run_id in run_ids:
|
|
34
|
+
tree = worktree_base / run_id
|
|
35
|
+
try:
|
|
36
|
+
if tree.is_symlink():
|
|
37
|
+
continue
|
|
38
|
+
if tree.is_dir():
|
|
39
|
+
trees.append(tree)
|
|
40
|
+
except OSError:
|
|
41
|
+
# Unreadable worktree base (permission-denied) — skip; never abort.
|
|
42
|
+
continue
|
|
43
|
+
return trees
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def repo_owned_orphan_trees(
|
|
47
|
+
repo_root: Path, candidate_trees: list[Path], known_run_ids: set[str]
|
|
48
|
+
) -> list[Path]:
|
|
49
|
+
"""From ``candidate_trees`` (immediate subdirs of the worktree base), return
|
|
50
|
+
those whose run-id is GONE (not in ``known_run_ids``) AND which are provably
|
|
51
|
+
owned by ``repo_root`` — i.e. a worktree registered in this repo's
|
|
52
|
+
``git worktree list`` lives strictly under the tree. A tree we cannot prove
|
|
53
|
+
is ours (e.g. another repo's, a stale registry path, or a stray dir) is LEFT
|
|
54
|
+
(the brief's "when unsure, leave it")."""
|
|
55
|
+
repo_resolved = _resolve_best_effort(repo_root)
|
|
56
|
+
registered = _active_repo_worktree_paths(repo_root, repo_resolved)
|
|
57
|
+
if not registered:
|
|
58
|
+
return []
|
|
59
|
+
orphans: list[Path] = []
|
|
60
|
+
for sub in candidate_trees:
|
|
61
|
+
if sub.name in known_run_ids:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
if sub.is_symlink():
|
|
65
|
+
continue
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
sub_resolved = sub.resolve()
|
|
70
|
+
except OSError:
|
|
71
|
+
continue
|
|
72
|
+
if tree_contains_repo_root(sub_resolved, repo_resolved):
|
|
73
|
+
continue
|
|
74
|
+
if any(reg != sub_resolved and _is_at_or_under(reg, sub_resolved) for reg in registered):
|
|
75
|
+
orphans.append(sub)
|
|
76
|
+
return sorted(orphans)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def tree_contains_repo_root(tree: Path, repo_root: Path) -> bool:
|
|
80
|
+
"""True when removing ``tree`` would remove the main checkout."""
|
|
81
|
+
tree_resolved = _resolve_best_effort(tree)
|
|
82
|
+
repo_resolved = _resolve_best_effort(repo_root)
|
|
83
|
+
return _is_at_or_under(repo_resolved, tree_resolved)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def tree_identity(tree: Path) -> tuple[int, int, int] | None:
|
|
87
|
+
"""Best-effort identity token for a non-symlink directory.
|
|
88
|
+
|
|
89
|
+
Includes ``st_ctime_ns`` alongside ``(st_dev, st_ino)`` because inode
|
|
90
|
+
numbers are reused on Linux: a tree deleted and recreated between GC
|
|
91
|
+
planning and execution can land on the SAME inode, so ``(st_dev, st_ino)``
|
|
92
|
+
alone would still match and GC would delete the replacement. A recreated
|
|
93
|
+
directory has a newer change-time, so including ``st_ctime_ns`` catches the
|
|
94
|
+
swap. (macOS does not reuse the inode in this window, which is why the gap
|
|
95
|
+
only surfaced on Linux/CI.) In the normal plan→execute window nothing
|
|
96
|
+
touches the tree, so ``st_ctime_ns`` is stable and legitimate removals still
|
|
97
|
+
proceed.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
if tree.is_symlink():
|
|
101
|
+
return None
|
|
102
|
+
stat = tree.stat()
|
|
103
|
+
except OSError:
|
|
104
|
+
return None
|
|
105
|
+
return (stat.st_dev, stat.st_ino, stat.st_ctime_ns)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _active_repo_worktree_paths(repo_root: Path, repo_resolved: Path) -> set[Path]:
|
|
109
|
+
"""Registered worktree paths that still exist and belong to ``repo_root``.
|
|
110
|
+
|
|
111
|
+
``git worktree list`` can retain stale paths after a worktree directory is
|
|
112
|
+
removed out-of-band. A stale path string under ``/tmp/syncade/<id>/`` is not
|
|
113
|
+
proof that the *current* directory at ``<id>`` is ours; another repo or a
|
|
114
|
+
human could have recreated that tree. Re-prove ownership through Git before
|
|
115
|
+
using the registered path as GC authority.
|
|
116
|
+
"""
|
|
117
|
+
repo_common_dir = _git_common_dir(repo_root)
|
|
118
|
+
if repo_common_dir is None:
|
|
119
|
+
return set()
|
|
120
|
+
|
|
121
|
+
active: set[Path] = set()
|
|
122
|
+
for path in _registered_worktree_paths(repo_root):
|
|
123
|
+
resolved = _resolve_best_effort(path)
|
|
124
|
+
if resolved == repo_resolved:
|
|
125
|
+
continue
|
|
126
|
+
if not _existing_non_symlink_dir(resolved):
|
|
127
|
+
continue
|
|
128
|
+
if _git_common_dir(resolved) == repo_common_dir:
|
|
129
|
+
active.add(resolved)
|
|
130
|
+
return active
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _registered_worktree_paths(repo_root: Path) -> set[Path]:
|
|
134
|
+
"""Resolved paths of every worktree registered in ``repo_root``'s
|
|
135
|
+
``git worktree list`` (best-effort: empty set if git is missing/errors or the
|
|
136
|
+
dir is not a git repo)."""
|
|
137
|
+
try:
|
|
138
|
+
result = run_subprocess(
|
|
139
|
+
["git", "worktree", "list", "--porcelain"],
|
|
140
|
+
cwd=repo_root,
|
|
141
|
+
timeout=_GIT_WORKTREE_LIST_TIMEOUT_SECONDS,
|
|
142
|
+
)
|
|
143
|
+
except SubprocessError:
|
|
144
|
+
return set()
|
|
145
|
+
if result.returncode != 0:
|
|
146
|
+
return set()
|
|
147
|
+
paths: set[Path] = set()
|
|
148
|
+
for line in result.stdout.splitlines():
|
|
149
|
+
if line.startswith("worktree "):
|
|
150
|
+
raw = line[len("worktree ") :].strip()
|
|
151
|
+
try:
|
|
152
|
+
paths.add(Path(raw).resolve())
|
|
153
|
+
except OSError:
|
|
154
|
+
paths.add(Path(raw))
|
|
155
|
+
return paths
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _git_common_dir(path: Path) -> Path | None:
|
|
159
|
+
try:
|
|
160
|
+
result = run_subprocess(
|
|
161
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
162
|
+
cwd=path,
|
|
163
|
+
timeout=_GIT_WORKTREE_LIST_TIMEOUT_SECONDS,
|
|
164
|
+
)
|
|
165
|
+
except SubprocessError:
|
|
166
|
+
return None
|
|
167
|
+
if result.returncode != 0:
|
|
168
|
+
return None
|
|
169
|
+
raw = result.stdout.strip()
|
|
170
|
+
if not raw:
|
|
171
|
+
return None
|
|
172
|
+
common_dir = Path(raw)
|
|
173
|
+
if not common_dir.is_absolute():
|
|
174
|
+
common_dir = path / common_dir
|
|
175
|
+
return _resolve_best_effort(common_dir)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_at_or_under(path: Path, ancestor: Path) -> bool:
|
|
179
|
+
"""True if ``path`` is ``ancestor`` or a descendant of it."""
|
|
180
|
+
try:
|
|
181
|
+
path.relative_to(ancestor)
|
|
182
|
+
return True
|
|
183
|
+
except ValueError:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _existing_non_symlink_dir(path: Path) -> bool:
|
|
188
|
+
try:
|
|
189
|
+
if path.is_symlink():
|
|
190
|
+
return False
|
|
191
|
+
return path.is_dir()
|
|
192
|
+
except OSError:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolve_best_effort(path: Path) -> Path:
|
|
197
|
+
try:
|
|
198
|
+
return path.resolve()
|
|
199
|
+
except OSError:
|
|
200
|
+
return path
|
syncade/git_object_id.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
_SHA1_HEX_LENGTH: Final = 40
|
|
6
|
+
_SHA256_HEX_LENGTH: Final = 64
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_full_git_object_id(value: str) -> bool:
|
|
10
|
+
return len(value) in {_SHA1_HEX_LENGTH, _SHA256_HEX_LENGTH} and all(
|
|
11
|
+
char in "0123456789abcdefABCDEF" for char in value
|
|
12
|
+
)
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Git precondition for the main ``syncade <pr-doc>`` run path: ensure an
|
|
2
|
+
enclosing git repository exists, initializing one when it does not.
|
|
3
|
+
|
|
4
|
+
syncade is built entirely on git — the reviewer diff, the worktree
|
|
5
|
+
isolation, SHA-based stall detection, and branch advance all require a
|
|
6
|
+
repository. For the novice / "vibe coder" audience, "no ``git init`` yet"
|
|
7
|
+
is the common starting state, not an edge case. Rather than hard-stopping
|
|
8
|
+
at exit 60 (the historical :func:`~syncade.snapshot.discover_repo_root`
|
|
9
|
+
behavior when invoked outside a working tree), the main run path initializes
|
|
10
|
+
a repo when the directory is empty: syncade gets the substrate it requires
|
|
11
|
+
and the user gets a version-control safety net they did not know to set up.
|
|
12
|
+
Non-empty directories are refused (see ``AutoInitRefusedError``); pass
|
|
13
|
+
``--allow-auto-init`` to opt in, accepting that git will commit existing
|
|
14
|
+
contents.
|
|
15
|
+
|
|
16
|
+
This is a *precondition*. It runs before ``take_snapshot`` and touches
|
|
17
|
+
nothing in the review loop — reviewers / synth / producer / verdict are
|
|
18
|
+
byte-unchanged. Only :func:`syncade.cli._run` (the real review path)
|
|
19
|
+
calls it; the diagnostic modes (``--selfcheck`` / ``--auth-check`` /
|
|
20
|
+
``--spec-audit`` / ``--resume``) keep ``discover_repo_root``'s hard-stop
|
|
21
|
+
behavior — they are not in the "review this work" flow and must not
|
|
22
|
+
mutate the operator's directory.
|
|
23
|
+
|
|
24
|
+
All git goes through :func:`syncade.process.run_subprocess` (the mandated
|
|
25
|
+
wrapper), mirroring
|
|
26
|
+
:func:`syncade.synthesizer.workspace._init_workspace_git` and the
|
|
27
|
+
``selfcheck.py`` init sequence.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import shutil
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from syncade.process import (
|
|
36
|
+
SubprocessError,
|
|
37
|
+
SubprocessNotFoundError,
|
|
38
|
+
run_subprocess,
|
|
39
|
+
)
|
|
40
|
+
from syncade.snapshot import SnapshotError, discover_repo_root
|
|
41
|
+
|
|
42
|
+
_GIT_TIMEOUT_SECONDS: float = 30.0
|
|
43
|
+
|
|
44
|
+
_GIT_MISSING_MESSAGE = (
|
|
45
|
+
"git is required but was not found on PATH. Install git and re-run "
|
|
46
|
+
"syncade (macOS: `xcode-select --install`; Debian/Ubuntu: "
|
|
47
|
+
"`sudo apt install git`; Windows: https://git-scm.com/download/win)."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_BASELINE_COMMIT_MESSAGE = "syncade: baseline commit"
|
|
51
|
+
|
|
52
|
+
# Conservative ignore patterns. Covers the secrets and junk a baseline commit
|
|
53
|
+
# must never sweep in (.env, private keys, syncade's own .syncade/secrets.toml,
|
|
54
|
+
# syncade-generated run state under .syncade/runs/ and .syncade/last-reviewed.json,
|
|
55
|
+
# dependency/build directories). .syncade/config.toml stays trackable — we ignore
|
|
56
|
+
# generated/run-state paths, not all of .syncade/. Kept deliberately short and
|
|
57
|
+
# editable — it is a safety floor, not a curated per-language ignore. These
|
|
58
|
+
# patterns are written to BOTH surfaces: always to the .git/info/exclude file
|
|
59
|
+
# (the secret-exclusion guarantee) and, as a starter-file nicety, to a visible
|
|
60
|
+
# .gitignore when that path is unoccupied.
|
|
61
|
+
_DEFAULT_GITIGNORE = """\
|
|
62
|
+
# syncade baseline ignore (auto-generated — edit freely)
|
|
63
|
+
.env
|
|
64
|
+
.env.*
|
|
65
|
+
credentials.json
|
|
66
|
+
.aws/
|
|
67
|
+
*.key
|
|
68
|
+
*.pem
|
|
69
|
+
id_rsa
|
|
70
|
+
id_dsa
|
|
71
|
+
id_ecdsa
|
|
72
|
+
id_ed25519
|
|
73
|
+
*.p12
|
|
74
|
+
*.pfx
|
|
75
|
+
.syncade/secrets.toml
|
|
76
|
+
.syncade/secrets.*
|
|
77
|
+
.syncade/secrets/
|
|
78
|
+
.syncade/runs/
|
|
79
|
+
.syncade/last-reviewed.json
|
|
80
|
+
.syncade/metrics.db
|
|
81
|
+
node_modules/
|
|
82
|
+
__pycache__/
|
|
83
|
+
*.pyc
|
|
84
|
+
.venv/
|
|
85
|
+
venv/
|
|
86
|
+
dist/
|
|
87
|
+
build/
|
|
88
|
+
.DS_Store
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# First-line marker used to make the .git/info/exclude append idempotent (so a
|
|
92
|
+
# re-init or repeated call never duplicates the block).
|
|
93
|
+
_EXCLUDE_MARKER = "# syncade baseline ignore"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AutoInitRefusedError(Exception):
|
|
97
|
+
"""Refusing to `git init` a directory that already has files in it.
|
|
98
|
+
|
|
99
|
+
Auto-init exists so a fresh directory can be reviewed without ceremony. It used to run
|
|
100
|
+
in ANY non-repo directory, committing whatever it found, guarded by a filename denylist
|
|
101
|
+
plus a starter `.gitignore`. Both were defeatable, and all three were measured:
|
|
102
|
+
|
|
103
|
+
- a key in ``deploy-notes.txt`` / ``backup-of-key.bak`` — names no denylist knows —
|
|
104
|
+
landed in git history;
|
|
105
|
+
- the operator's own ``.gitignore`` containing ``!.env`` re-included ``.env``;
|
|
106
|
+
- a symlinked ``.git`` was followed, so ``git init`` wrote OUTSIDE this directory.
|
|
107
|
+
|
|
108
|
+
Refusing a populated directory closes all three at once, with no denylist to maintain
|
|
109
|
+
and no content scanner to keep ahead of. The failure mode inverts too: the worst case is
|
|
110
|
+
now "refused a run the operator wanted" rather than "committed a secret".
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class GitUnavailableError(Exception):
|
|
115
|
+
"""Raised when the ``git`` binary is not on ``PATH``.
|
|
116
|
+
|
|
117
|
+
Distinct from :class:`~syncade.snapshot.SnapshotError` so the CLI can
|
|
118
|
+
map "git is not installed" to an actionable install message rather
|
|
119
|
+
than the generic "not inside a git repository" path. ``discover_repo_root``
|
|
120
|
+
collapses both into ``SnapshotError``; this precondition splits them
|
|
121
|
+
back apart so each gets the right remediation.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _git(cwd: Path, *args: str) -> None:
|
|
126
|
+
"""Run ``git <args>`` in ``cwd`` via the mandated wrapper, raising on a
|
|
127
|
+
non-zero exit.
|
|
128
|
+
|
|
129
|
+
Mirrors :func:`syncade.synthesizer.workspace._init_workspace_git`: a
|
|
130
|
+
non-zero return code from an init / add / commit step is a real
|
|
131
|
+
failure (workspace permissions, corrupt git config, ...) surfaced as a
|
|
132
|
+
:class:`~syncade.process.SubprocessError`. A missing ``git`` binary
|
|
133
|
+
raises :class:`~syncade.process.SubprocessNotFoundError`, which callers
|
|
134
|
+
that need the distinction handle before reaching here.
|
|
135
|
+
"""
|
|
136
|
+
result = run_subprocess(
|
|
137
|
+
["git", *args],
|
|
138
|
+
cwd=cwd,
|
|
139
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
140
|
+
)
|
|
141
|
+
if result.returncode != 0:
|
|
142
|
+
step = args[0] if args else "<none>"
|
|
143
|
+
raise SubprocessError(
|
|
144
|
+
f"git {step} failed in {cwd} (rc={result.returncode}); stderr: {result.stderr[:200]!r}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _write_repo_local_exclude(repo_root: Path) -> None:
|
|
149
|
+
"""Append the conservative safety patterns to ``.git/info/exclude``.
|
|
150
|
+
|
|
151
|
+
This is the secret-exclusion GUARANTEE — not the visible ``.gitignore``.
|
|
152
|
+
``.git/info/exclude`` is git's per-repo, never-committed supplemental
|
|
153
|
+
ignore: ``git add -A`` honors it exactly like ``.gitignore``, but it lives
|
|
154
|
+
inside ``.git/`` so it neither clobbers nor can be defeated by whatever the
|
|
155
|
+
user has at the ``.gitignore`` path (a missing/partial file, a directory,
|
|
156
|
+
or a symlink). Writing the patterns here is what actually makes the
|
|
157
|
+
baseline commit safe regardless of the ``.gitignore`` situation — the
|
|
158
|
+
AC2-vs-never-clobber conflict the validation surfaced.
|
|
159
|
+
|
|
160
|
+
The path is resolved via ``git rev-parse --git-path info/exclude`` so it is
|
|
161
|
+
correct regardless of the ``.git`` layout; the block is appended
|
|
162
|
+
idempotently (guarded by :data:`_EXCLUDE_MARKER`) so git's default
|
|
163
|
+
template and any pre-existing excludes are preserved.
|
|
164
|
+
|
|
165
|
+
As a defense-in-depth backstop, the resolved exclude path is checked to
|
|
166
|
+
remain within ``repo_root``. A symlinked ``info/exclude`` leaf that slipped
|
|
167
|
+
past the pre-init directory check would otherwise cause writes outside the
|
|
168
|
+
repository directory.
|
|
169
|
+
"""
|
|
170
|
+
result = run_subprocess(
|
|
171
|
+
["git", "rev-parse", "--git-path", "info/exclude"],
|
|
172
|
+
cwd=repo_root,
|
|
173
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
174
|
+
)
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
raise SubprocessError(
|
|
177
|
+
f"git rev-parse --git-path info/exclude failed in {repo_root} "
|
|
178
|
+
f"(rc={result.returncode}); stderr: {result.stderr[:200]!r}"
|
|
179
|
+
)
|
|
180
|
+
# --git-path returns a path relative to repo_root (or absolute); Path
|
|
181
|
+
# division handles both. resolve() follows any symlinks.
|
|
182
|
+
exclude_path = (repo_root / result.stdout.strip()).resolve()
|
|
183
|
+
# Guard: the resolved path must stay inside the repo root. A symlinked
|
|
184
|
+
# .git/info/exclude pointing outside would let this writer escape the
|
|
185
|
+
# directory the operator named. The pre-init check catches this case
|
|
186
|
+
# before git init runs; this is the backstop for any path it missed.
|
|
187
|
+
try:
|
|
188
|
+
exclude_path.relative_to(repo_root.resolve())
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise SubprocessError(
|
|
191
|
+
f"refusing to write exclude rules: git rev-parse --git-path returned a path "
|
|
192
|
+
f"outside the repo ({exclude_path!r}). A symlink at .git/info/exclude "
|
|
193
|
+
f"likely points outside the directory you named. Remove it and retry."
|
|
194
|
+
) from exc
|
|
195
|
+
exclude_path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
existing = exclude_path.read_text(encoding="utf-8") if exclude_path.is_file() else ""
|
|
197
|
+
if _EXCLUDE_MARKER in existing:
|
|
198
|
+
return # already present — idempotent
|
|
199
|
+
separator = "" if existing == "" or existing.endswith("\n") else "\n"
|
|
200
|
+
exclude_path.write_text(existing + separator + _DEFAULT_GITIGNORE, encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#: The only entry undo removes. ``.gitignore`` and ``.syncade`` deliberately survive —
|
|
204
|
+
#: see :func:`undo_auto_init` for why each one does.
|
|
205
|
+
_UNDO_TARGETS = (".git",)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def undo_auto_init(root: Path) -> list[str]:
|
|
209
|
+
"""Remove the repository auto-init created under ``root``. **``.git`` and nothing else.**
|
|
210
|
+
|
|
211
|
+
Only ever called for a directory that was EMPTY before syncade touched it, so ownership
|
|
212
|
+
is settled before this function runs: everything present now arrived during this
|
|
213
|
+
invocation. There is deliberately **no ownership check here** — no inode comparison, no
|
|
214
|
+
content hashing, no marker file. Three successive attempts at proving ownership (inode,
|
|
215
|
+
then path name, then content) each had a hole, which is why the emptiness bit replaced
|
|
216
|
+
them.
|
|
217
|
+
|
|
218
|
+
**Why only ``.git``.** Auto-init creates a repository; that is the mutation being undone.
|
|
219
|
+
Two things it leaves alone, each for its own reason:
|
|
220
|
+
|
|
221
|
+
- the starter ``.gitignore``, because emptiness settles who **created** a file, not who
|
|
222
|
+
last **wrote** it — an operator can edit it between the check and the refusal, and
|
|
223
|
+
deleting it anyway is an ownership claim about live content.
|
|
224
|
+
- ``.syncade/``, because run state is not auto-init's to remove. ``.syncade/runs/`` is
|
|
225
|
+
governed by a retention rule that predates this function and is explicit: GC never
|
|
226
|
+
deletes a run directory, because ``.syncade/metrics.db`` is a *derived, rebuildable
|
|
227
|
+
view* over that tree and loses its history when a run disappears. A blanket
|
|
228
|
+
``rmtree`` here contradicted that rule, and a four-round dogfood spent itself on the
|
|
229
|
+
symptoms — the run advertising artifact paths that the same invocation then deleted,
|
|
230
|
+
chased through three separate print sites before the cause was named.
|
|
231
|
+
|
|
232
|
+
So nothing this function removes is ever advertised, read by metrics, or authored by
|
|
233
|
+
anyone but git. A refusal leaves a directory with no repository in it; that is the whole
|
|
234
|
+
claim, and it is smaller than "restores it to empty" on purpose.
|
|
235
|
+
|
|
236
|
+
Returns the paths it could not remove; never raises. The run is already failing when this
|
|
237
|
+
is called, and masking a cleanup error would trade a clear failure for a confusing one. A
|
|
238
|
+
leftover repository is the OLD behaviour, so a failed undo is a regression to it rather
|
|
239
|
+
than a new hazard.
|
|
240
|
+
"""
|
|
241
|
+
failed: list[str] = []
|
|
242
|
+
for name in _UNDO_TARGETS:
|
|
243
|
+
path = root / name
|
|
244
|
+
try:
|
|
245
|
+
if path.is_symlink() or path.is_file():
|
|
246
|
+
path.unlink()
|
|
247
|
+
elif path.is_dir():
|
|
248
|
+
shutil.rmtree(path)
|
|
249
|
+
except OSError:
|
|
250
|
+
failed.append(str(path))
|
|
251
|
+
return failed
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def ensure_repo_initialized(start_path: Path, *, allow_populated: bool = False) -> Path:
|
|
255
|
+
"""Ensure ``start_path`` lives inside a git repo, initializing one if not.
|
|
256
|
+
|
|
257
|
+
Returns the repo root in every success case:
|
|
258
|
+
|
|
259
|
+
- An enclosing repo (at ``start_path`` or any ancestor) already exists
|
|
260
|
+
→ return its root with **no side effects** (today's
|
|
261
|
+
``discover_repo_root`` behavior; parent-repo discovery preserved).
|
|
262
|
+
- No enclosing repo and ``git`` is present → ``git init`` at
|
|
263
|
+
``start_path``, write the conservative ignore patterns to
|
|
264
|
+
``.git/info/exclude`` (the secret-exclusion guarantee) and a starter
|
|
265
|
+
``.gitignore`` (only when that path is unoccupied), make a safe baseline
|
|
266
|
+
commit, and return the new root.
|
|
267
|
+
- ``git`` is not on ``PATH`` → raise :class:`GitUnavailableError`.
|
|
268
|
+
|
|
269
|
+
Detection reuses :func:`~syncade.snapshot.discover_repo_root` (the
|
|
270
|
+
canonical ``git rev-parse --show-toplevel`` path) — no second
|
|
271
|
+
detection routine.
|
|
272
|
+
|
|
273
|
+
Safe-add ordering is load-bearing: both ignore surfaces are written
|
|
274
|
+
*before* ``git add -A`` so the baseline commit cannot capture secrets
|
|
275
|
+
(``.env``, ``*.key``) or junk (``node_modules/``, build artifacts). For
|
|
276
|
+
an empty directory (the default path) this guarantee is unconditional —
|
|
277
|
+
no ``.gitignore`` can override it because the directory is empty. With
|
|
278
|
+
``allow_populated=True`` the guarantee weakens: an existing ``.gitignore``
|
|
279
|
+
containing ``!.env`` or similar negations can re-include excluded paths.
|
|
280
|
+
The exclude list lives in ``.git/info/exclude`` (see
|
|
281
|
+
:func:`_write_repo_local_exclude`); an existing ``.gitignore`` at the
|
|
282
|
+
project root is never clobbered.
|
|
283
|
+
"""
|
|
284
|
+
# Happy path: an enclosing repo already exists (here or in a parent).
|
|
285
|
+
# discover_repo_root reuses the canonical detection and returns the
|
|
286
|
+
# resolved root with no side effects.
|
|
287
|
+
try:
|
|
288
|
+
return discover_repo_root(start_path)
|
|
289
|
+
except SnapshotError:
|
|
290
|
+
# No enclosing repo, OR git itself is missing — discover_repo_root
|
|
291
|
+
# collapses both into SnapshotError. Disambiguate below before
|
|
292
|
+
# deciding to initialize.
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
# Distinguish "git binary missing" from "no repo here". The version
|
|
296
|
+
# probe is cwd-independent, so it isolates "is git on PATH" from
|
|
297
|
+
# "does start_path resolve to a working tree" — only the former is
|
|
298
|
+
# GitUnavailableError.
|
|
299
|
+
try:
|
|
300
|
+
run_subprocess(["git", "--version"], timeout=_GIT_TIMEOUT_SECONDS)
|
|
301
|
+
except SubprocessNotFoundError as exc:
|
|
302
|
+
raise GitUnavailableError(_GIT_MISSING_MESSAGE) from exc
|
|
303
|
+
|
|
304
|
+
# Only auto-init an EMPTY directory (PR-h-04 item B, decision D1(a)). See
|
|
305
|
+
# AutoInitRefusedError for the three measured leaks this closes. The documented
|
|
306
|
+
# onboarding path is `cd your-git-repo`, so an existing repo never reaches here.
|
|
307
|
+
if not allow_populated and any(start_path.iterdir()):
|
|
308
|
+
raise AutoInitRefusedError(
|
|
309
|
+
f"refusing to create a git repository in {start_path}: it is not empty, and "
|
|
310
|
+
f"auto-init would commit its current contents.\n"
|
|
311
|
+
f" If you want these files reviewed, initialize the repo yourself so you can "
|
|
312
|
+
f"see exactly what is committed:\n"
|
|
313
|
+
f" git init && git add -A && git status && git commit -m 'baseline'\n"
|
|
314
|
+
f" An empty directory is always auto-initialized. To let syncade do it here "
|
|
315
|
+
f"anyway, pass --allow-auto-init."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# ANY pre-existing `.git` refuses auto-init, whatever form it takes and whatever
|
|
319
|
+
# --allow-auto-init says.
|
|
320
|
+
#
|
|
321
|
+
# This is reached ONLY when `discover_repo_root` already failed, i.e. there is no valid
|
|
322
|
+
# repository here. So a `.git` that exists at this point is broken, partial, or hostile,
|
|
323
|
+
# and initializing over it is never the right answer — which means syncade does not need
|
|
324
|
+
# to work out WHAT is wrong with it.
|
|
325
|
+
#
|
|
326
|
+
# That matters because classifying it does not terminate. Four dogfood rounds tried:
|
|
327
|
+
# a symlinked `.git`, then top-level symlinks inside it, then symlinks at any depth via
|
|
328
|
+
# os.walk, and the terminal round still found malformed REGULAR control paths. Each fix
|
|
329
|
+
# was correct and each left a sibling shape. Refusing the whole category replaces ~35
|
|
330
|
+
# lines of scanning with one condition and closes every shape at once, including ones
|
|
331
|
+
# nobody has thought of.
|
|
332
|
+
_git_path = start_path / ".git"
|
|
333
|
+
if _git_path.is_symlink() or _git_path.exists():
|
|
334
|
+
raise AutoInitRefusedError(
|
|
335
|
+
f"refusing to initialize a git repository in {start_path}: it already contains a "
|
|
336
|
+
f".git entry, but that is not a usable repository. Auto-init would write over or "
|
|
337
|
+
f"through it. Inspect .git yourself — repair or remove it — then re-run."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# git is present and there is no enclosing repo → initialize one. Set the
|
|
341
|
+
# initial branch to `main` via symbolic-ref (supported since git 1.7)
|
|
342
|
+
# instead of `git init --initial-branch=main` (git >= 2.28 only), so
|
|
343
|
+
# auto-init works on older platforms (e.g. Ubuntu 20.04's git 2.25). On an
|
|
344
|
+
# unborn HEAD this just repoints the symbolic ref; the baseline commit then
|
|
345
|
+
# lands on `main`, identical to --initial-branch on modern git.
|
|
346
|
+
#
|
|
347
|
+
_git(start_path, "init", "--quiet")
|
|
348
|
+
_git(start_path, "symbolic-ref", "HEAD", "refs/heads/main")
|
|
349
|
+
|
|
350
|
+
# SAFETY GUARANTEE: write the conservative ignore patterns to
|
|
351
|
+
# .git/info/exclude BEFORE staging, so the baseline commit cannot sweep in
|
|
352
|
+
# secrets/junk — independent of whether/what .gitignore exists (or whether
|
|
353
|
+
# the .gitignore path is a file, directory, or symlink). See
|
|
354
|
+
# _write_repo_local_exclude.
|
|
355
|
+
_write_repo_local_exclude(start_path)
|
|
356
|
+
|
|
357
|
+
# NICETY (not the safety mechanism): give a brand-new project a visible,
|
|
358
|
+
# committed starter .gitignore — but only when the path is genuinely
|
|
359
|
+
# unoccupied. An existing .gitignore (regular file, directory, or symlink
|
|
360
|
+
# incl. dangling — `exists()` follows symlinks, so `is_symlink()` also
|
|
361
|
+
# guards the dangling case) is left untouched: never clobbered, and secret
|
|
362
|
+
# exclusion does not depend on it (guaranteed by .git/info/exclude above).
|
|
363
|
+
#
|
|
364
|
+
# NOTE: nothing here records what it creates. A partial failure needs no receipt —
|
|
365
|
+
# the caller knows the directory was EMPTY beforehand, so undo removes whatever is
|
|
366
|
+
# there, complete or not. That is why ~40 lines of _partial/_full/_stats tracking and
|
|
367
|
+
# inode capture are gone (PR-h-04.6): four dogfood rounds could not make them prove
|
|
368
|
+
# ownership, and emptiness makes the question unnecessary.
|
|
369
|
+
gitignore = start_path / ".gitignore"
|
|
370
|
+
if not gitignore.exists() and not gitignore.is_symlink():
|
|
371
|
+
gitignore.write_text(_DEFAULT_GITIGNORE, encoding="utf-8")
|
|
372
|
+
|
|
373
|
+
# Stage everything (filtered by .git/info/exclude + any .gitignore) and
|
|
374
|
+
# make the baseline commit. --allow-empty covers the case where nothing is
|
|
375
|
+
# left to stage — e.g. a pre-existing .gitignore that ignores everything
|
|
376
|
+
# (content `*`); it is a no-op when there ARE staged changes, so it never
|
|
377
|
+
# forces a spurious empty commit in the common case.
|
|
378
|
+
_git(start_path, "add", "-A")
|
|
379
|
+
_git(start_path, "commit", "--quiet", "--allow-empty", "-m", _BASELINE_COMMIT_MESSAGE)
|
|
380
|
+
|
|
381
|
+
# PR-v2-26: leave HEAD on a feature branch, not the auto-created `main`. Otherwise a
|
|
382
|
+
# first run in a fresh dir would land on `main` and the default-branch commit guard
|
|
383
|
+
# would refuse the committing loop — bad onboarding. `main` stays the clean baseline;
|
|
384
|
+
# syncade's producer commits go on `syncade-work`.
|
|
385
|
+
_git(start_path, "checkout", "--quiet", "-b", "syncade-work")
|
|
386
|
+
|
|
387
|
+
# Re-resolve via the canonical path: confirms the init took and returns
|
|
388
|
+
# git's own toplevel (resolved, symlink-normalized).
|
|
389
|
+
return discover_repo_root(start_path)
|