syncade 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
@@ -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
@@ -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)