test_gsw_write_path.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
3 days ago
| 1 | """Unit tests for the §GSW governance-sync write-path order (§GSW.10 unit tier). |
| 2 | |
| 3 | Covers: apply-plan ordering (realign → branch ensure → doc writes), |
| 4 | ``commit_feature`` already-on-branch short-circuit for all three adapters, |
| 5 | Muse dirty-carry checkout, branch-state capture/restore, and |
| 6 | marker-not-written-on-commit-failure. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | from pathlib import Path |
| 12 | |
| 13 | from cli.kit_root import kit_root |
| 14 | from tests.support import ( |
| 15 | adapter_for, |
| 16 | fail, |
| 17 | gsw_runner, |
| 18 | make_runner, |
| 19 | ok, |
| 20 | run_cli, |
| 21 | seed_gsw_repo, |
| 22 | ) |
| 23 | from tools.governance_hygiene.engine import ( |
| 24 | BranchState, |
| 25 | _capture_branch_state, |
| 26 | _restore_branch_state, |
| 27 | ) |
| 28 | |
| 29 | |
| 30 | class _EventRunner: |
| 31 | """Wrap a runner and interleave its commands into a shared event list.""" |
| 32 | |
| 33 | def __init__(self, inner, events: list) -> None: |
| 34 | self.inner = inner |
| 35 | self.events = events |
| 36 | |
| 37 | def run(self, command: str, *, cwd: str | None = None): |
| 38 | self.events.append(("cmd", command)) |
| 39 | return self.inner.run(command, cwd=cwd) |
| 40 | |
| 41 | @property |
| 42 | def calls(self): |
| 43 | return self.inner.calls |
| 44 | |
| 45 | |
| 46 | def test_apply_plan_realign_then_ensure_then_writes(tmp_path: Path, monkeypatch) -> None: |
| 47 | """§GSW.3.1: realign runs on the original branch before branch ensure, |
| 48 | and no doc write precedes successful branch ensure.""" |
| 49 | seed_gsw_repo(tmp_path, "git-only") |
| 50 | events: list = [] |
| 51 | |
| 52 | from tools.governance_hygiene import engine as engine_mod |
| 53 | |
| 54 | real_guard = engine_mod.execute_realign_guard |
| 55 | |
| 56 | def recording_guard(config, adapter, reads, drift, *, dry_run): |
| 57 | events.append(("realign", dry_run)) |
| 58 | return real_guard(config, adapter, reads, drift, dry_run=dry_run) |
| 59 | |
| 60 | real_write = engine_mod.atomic_write_text |
| 61 | |
| 62 | def recording_write(path: Path, text: str) -> None: |
| 63 | events.append(("write", path.name)) |
| 64 | real_write(path, text) |
| 65 | |
| 66 | monkeypatch.setattr(engine_mod, "execute_realign_guard", recording_guard) |
| 67 | monkeypatch.setattr(engine_mod, "atomic_write_text", recording_write) |
| 68 | |
| 69 | runner = _EventRunner(gsw_runner(tmp_path, "git-only"), events) |
| 70 | code = run_cli(["governance-sync", "--write"], cwd=tmp_path, runner=runner, kit=kit_root()) |
| 71 | assert code == 0 |
| 72 | |
| 73 | realign_apply = events.index(("realign", False)) |
| 74 | first_checkout = next( |
| 75 | index for index, event in enumerate(events) |
| 76 | if event[0] == "cmd" and "git checkout -b" in event[1] |
| 77 | ) |
| 78 | doc_writes = [ |
| 79 | index for index, event in enumerate(events) |
| 80 | if event[0] == "write" and event[1] in {"OVERSEER-HANDOVER.md", "ROADMAP.md"} |
| 81 | ] |
| 82 | assert doc_writes, "expected handover/roadmap writes" |
| 83 | assert realign_apply < first_checkout, "realign must run before branch ensure" |
| 84 | assert first_checkout < min(doc_writes), "no doc write may precede branch ensure" |
| 85 | |
| 86 | |
| 87 | def test_commit_feature_short_circuit_git_only(git_only_config, repo_root) -> None: |
| 88 | """§GSW.6.1: already on branch → skip checkout, commit dirty docs.""" |
| 89 | runner = make_runner( |
| 90 | { |
| 91 | "git rev-parse --abbrev-ref HEAD": ok("feat/gsw"), |
| 92 | "git add": ok(""), |
| 93 | "git commit": ok(""), |
| 94 | "git rev-parse HEAD": ok("feedface"), |
| 95 | } |
| 96 | ) |
| 97 | adapter = adapter_for(git_only_config, repo_root, runner) |
| 98 | result = adapter.commit_feature(branch="feat/gsw", message="m", paths=["docs/ROADMAP.md"]) |
| 99 | assert result.committed is True |
| 100 | assert not any("checkout" in call[0] for call in runner.calls) |
| 101 | |
| 102 | |
| 103 | def test_commit_feature_short_circuit_muse_only(muse_only_config, repo_root) -> None: |
| 104 | root = str(repo_root) |
| 105 | runner = make_runner( |
| 106 | { |
| 107 | f"muse -C {root} rev-parse --abbrev-ref HEAD": ok("feat/gsw"), |
| 108 | f"muse -C {root} code add": ok(""), |
| 109 | f"muse -C {root} commit": ok(""), |
| 110 | f"muse -C {root} rev-parse HEAD": ok("sha256:abc"), |
| 111 | } |
| 112 | ) |
| 113 | adapter = adapter_for(muse_only_config, repo_root, runner) |
| 114 | result = adapter.commit_feature(branch="feat/gsw", message="m", paths=["docs/R.md"]) |
| 115 | assert result.committed is True |
| 116 | assert not any("checkout" in call[0] for call in runner.calls) |
| 117 | _assert_muse_stage_uses_code_add(runner.calls) |
| 118 | |
| 119 | |
| 120 | def test_commit_feature_short_circuit_muse_git_mirror(muse_git_mirror_config, repo_root) -> None: |
| 121 | root = str(repo_root) |
| 122 | runner = make_runner( |
| 123 | { |
| 124 | f"muse -C {root} rev-parse --abbrev-ref HEAD": ok("feat/gsw"), |
| 125 | f"muse -C {root} code add": ok(""), |
| 126 | f"muse -C {root} commit": ok(""), |
| 127 | f"muse -C {root} rev-parse HEAD": ok("sha256:abc"), |
| 128 | } |
| 129 | ) |
| 130 | adapter = adapter_for(muse_git_mirror_config, repo_root, runner) |
| 131 | result = adapter.commit_feature(branch="feat/gsw", message="m", paths=["docs/R.md"]) |
| 132 | assert result.committed is True |
| 133 | assert not any("checkout" in call[0] for call in runner.calls) |
| 134 | _assert_muse_stage_uses_code_add(runner.calls) |
| 135 | |
| 136 | |
| 137 | def _assert_muse_stage_uses_code_add(calls) -> None: |
| 138 | """Live Muse 0.2.x has no top-level `add` — staging must be `muse code add` |
| 139 | (GSW land-b live regression).""" |
| 140 | stage_calls = [call[0] for call in calls if " add " in call[0] or call[0].endswith(" add")] |
| 141 | assert stage_calls, "expected a staging command" |
| 142 | for command in stage_calls: |
| 143 | assert " code add" in command, f"bare `muse add` is not a live subcommand: {command}" |
| 144 | |
| 145 | |
| 146 | def test_muse_only_dirty_off_branch_uses_autoshelf(muse_only_config, repo_root) -> None: |
| 147 | """§GSW.6.2: off-branch + dirty tree → dirty-carry flag, never bare-checkout-only.""" |
| 148 | runner = gsw_runner( |
| 149 | repo_root, |
| 150 | "muse-only", |
| 151 | muse_branch="main", |
| 152 | muse_dirty=True, |
| 153 | existing_muse_branches={"feat/gsw"}, |
| 154 | ) |
| 155 | adapter = adapter_for(muse_only_config, repo_root, runner) |
| 156 | result = adapter.commit_feature(branch="feat/gsw", message="m", paths=["docs/R.md"]) |
| 157 | assert result.committed is True |
| 158 | assert runner.muse_branch == "feat/gsw" |
| 159 | assert any("--autoshelf" in call[0] for call in runner.calls) |
| 160 | assert not any("--force" in call[0] for call in runner.calls) |
| 161 | |
| 162 | |
| 163 | def test_muse_git_mirror_dirty_off_branch_uses_autoshelf(muse_git_mirror_config, repo_root) -> None: |
| 164 | runner = gsw_runner( |
| 165 | repo_root, |
| 166 | "muse+git-mirror", |
| 167 | muse_branch="main", |
| 168 | muse_dirty=True, |
| 169 | existing_muse_branches={"feat/gsw"}, |
| 170 | ) |
| 171 | adapter = adapter_for(muse_git_mirror_config, repo_root, runner) |
| 172 | result = adapter.commit_feature(branch="feat/gsw", message="m", paths=["docs/R.md"]) |
| 173 | assert result.committed is True |
| 174 | assert runner.muse_branch == "feat/gsw" |
| 175 | assert any("--autoshelf" in call[0] for call in runner.calls) |
| 176 | assert not any("--force" in call[0] for call in runner.calls) |
| 177 | |
| 178 | |
| 179 | def test_capture_branch_state_fails_closed_on_unreadable_head( |
| 180 | git_only_config, repo_root |
| 181 | ) -> None: |
| 182 | """§GSW.4.1: unreadable HEAD → (None, failing command); no fabrication.""" |
| 183 | runner = make_runner({"git rev-parse --abbrev-ref HEAD": fail("bad head")}) |
| 184 | adapter = adapter_for(git_only_config, repo_root, runner) |
| 185 | state, command = _capture_branch_state(git_only_config, adapter, runner, repo_root) |
| 186 | assert state is None |
| 187 | assert command == "git rev-parse --abbrev-ref HEAD" |
| 188 | |
| 189 | |
| 190 | def test_capture_branch_state_dual_fields_for_mirror(muse_git_mirror_config, repo_root) -> None: |
| 191 | runner = gsw_runner(repo_root, "muse+git-mirror", git_branch="work", muse_branch="work") |
| 192 | adapter = adapter_for(muse_git_mirror_config, repo_root, runner) |
| 193 | state, command = _capture_branch_state(muse_git_mirror_config, adapter, runner, repo_root) |
| 194 | assert command is None |
| 195 | assert state == BranchState(git_branch="work", muse_branch="work") |
| 196 | |
| 197 | |
| 198 | def test_restore_branch_state_restores_each_regime( |
| 199 | git_only_config, muse_only_config, muse_git_mirror_config, repo_root |
| 200 | ) -> None: |
| 201 | """§GSW.4.2: rollback returns HEAD(s) to the captured original branch.""" |
| 202 | cases = [ |
| 203 | (git_only_config, "git-only"), |
| 204 | (muse_only_config, "muse-only"), |
| 205 | (muse_git_mirror_config, "muse+git-mirror"), |
| 206 | ] |
| 207 | for config, regime in cases: |
| 208 | runner = gsw_runner( |
| 209 | repo_root, |
| 210 | regime, |
| 211 | git_branch="feat/gsw", |
| 212 | muse_branch="feat/gsw", |
| 213 | existing_git_branches={"main"}, |
| 214 | existing_muse_branches={"main"}, |
| 215 | ) |
| 216 | adapter = adapter_for(config, repo_root, runner) |
| 217 | state = BranchState(git_branch="main", muse_branch="main") |
| 218 | errors = _restore_branch_state(config, adapter, runner, repo_root, state) |
| 219 | assert errors == () |
| 220 | if regime in {"git-only", "muse+git-mirror"}: |
| 221 | assert runner.git_branch == "main" |
| 222 | if regime in {"muse-only", "muse+git-mirror"}: |
| 223 | assert runner.muse_branch == "main" |
| 224 | assert not any("--force" in call[0] for call in runner.calls) |
| 225 | |
| 226 | |
| 227 | def test_restore_branch_state_dirty_muse_uses_autoshelf( |
| 228 | muse_git_mirror_config, repo_root |
| 229 | ) -> None: |
| 230 | """§GSW.4.3: dirty Muse restore falls back to --autoshelf, never --force.""" |
| 231 | runner = gsw_runner( |
| 232 | repo_root, |
| 233 | "muse+git-mirror", |
| 234 | git_branch="feat/gsw", |
| 235 | muse_branch="feat/gsw", |
| 236 | muse_dirty=True, |
| 237 | existing_git_branches={"main"}, |
| 238 | existing_muse_branches={"main"}, |
| 239 | ) |
| 240 | adapter = adapter_for(muse_git_mirror_config, repo_root, runner) |
| 241 | state = BranchState(git_branch="main", muse_branch="main") |
| 242 | errors = _restore_branch_state(muse_git_mirror_config, adapter, runner, repo_root, state) |
| 243 | assert errors == () |
| 244 | assert runner.muse_branch == "main" |
| 245 | assert runner.git_branch == "main" |
| 246 | assert any("--autoshelf" in call[0] for call in runner.calls) |
| 247 | assert not any("--force" in call[0] for call in runner.calls) |
| 248 | |
| 249 | |
| 250 | def test_marker_not_written_when_commit_fails(tmp_path: Path) -> None: |
| 251 | """§GSW.3.4: sync marker is written only after successful commit_feature.""" |
| 252 | seed_gsw_repo(tmp_path, "git-only") |
| 253 | runner = gsw_runner(tmp_path, "git-only", git_commit_fails=True) |
| 254 | code = run_cli(["governance-sync", "--write"], cwd=tmp_path, runner=runner, kit=kit_root()) |
| 255 | assert code == 2 |
| 256 | assert not (tmp_path / ".overseer" / "last_governance_sync").exists() |
| 257 | assert runner.git_branch == "main" |
File History
1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
3 days ago