test_adoption.py
python
sha256:ff22b6a82a08c117176c3e2603264a0e49fe8204ce57c3cefe7f5b165a8634f8
docs: recenter public story on governance loop
Human
7 days ago
| 1 | """Git-only migration and protocol refusals require no Muse installation.""" |
| 2 | import argparse |
| 3 | import json |
| 4 | from pathlib import Path |
| 5 | import subprocess |
| 6 | from unittest.mock import patch |
| 7 | |
| 8 | import pytest |
| 9 | import yaml |
| 10 | |
| 11 | from cli import v1 |
| 12 | from cli.v1_io import Refusal, digest |
| 13 | from cli.v1_policy import muse_ignore |
| 14 | from cli.v1_revision import MUSE_VERSION, muse_read |
| 15 | from tests.v1.conftest import command, seed, status |
| 16 | from tests.v1.test_commands import fingerprint |
| 17 | |
| 18 | |
| 19 | def adoption_args(root, **changes): |
| 20 | cfg = yaml.safe_load((root / v1.CONFIG).read_bytes()) |
| 21 | values = dict(vcs='git', expect_config=digest((root / v1.CONFIG).read_bytes()), |
| 22 | repo_id=cfg['repo']['id'], branch='main', base_head=v1.head(root), |
| 23 | lane=cfg['context']['lane'], model=cfg['context']['model']) |
| 24 | values.update(changes) |
| 25 | return [item for key, value in values.items() if value is not None |
| 26 | for item in ('--' + key.replace('_', '-'), value)] |
| 27 | |
| 28 | |
| 29 | def test_git_without_muse_and_no_automatic_conversion(repo): |
| 30 | # A broken Muse marker and poisoned installation variables never cause a |
| 31 | # configured Git checkout to load Muse or reinterpret its revision. |
| 32 | (repo / '.muse').mkdir() |
| 33 | (repo / '.muse/HEAD').write_text('invalid Muse metadata') |
| 34 | env = dict(PATH='/usr/bin:/bin', MUSE_REPO_ROOT='/missing', |
| 35 | MUSE_WORKTREE='/missing', MUSE_PYTHON='/missing') |
| 36 | before = fingerprint(repo) |
| 37 | for cmd in ('status', 'next'): |
| 38 | r = command('-C', repo, cmd, env=env) |
| 39 | assert r.returncode == 0, r.stderr |
| 40 | assert fingerprint(repo) == before |
| 41 | assert status(repo)['vcs'] == 'git' |
| 42 | r = command('-C', repo, 'init', '--vcs', 'muse') |
| 43 | assert r.returncode == 2 and 'explicit_adopt' in r.stderr |
| 44 | |
| 45 | |
| 46 | def test_mixed_init_requires_explicit_selection(tmp_path): |
| 47 | root = seed(tmp_path / 'mixed') |
| 48 | (root / '.muse').mkdir() |
| 49 | r = command('-C', root, 'init') |
| 50 | assert r.returncode == 2 and 'explicit_vcs_selection' in r.stderr |
| 51 | assert not (root / v1.CONFIG).exists() |
| 52 | assert command('-C', root, 'init', '--vcs', 'git').returncode == 0 |
| 53 | |
| 54 | |
| 55 | def test_nearest_muse_boundary_cannot_use_parent_git(repo): |
| 56 | child = repo / 'child' |
| 57 | (child / '.muse').mkdir(parents=True) |
| 58 | r = command('next', cwd=child) |
| 59 | assert r.returncode != 0 and not r.stdout |
| 60 | |
| 61 | |
| 62 | def test_schema_one_explicit_upgrade_and_exact_config_rollback(repo): |
| 63 | path = repo / v1.CONFIG |
| 64 | cfg = yaml.safe_load(path.read_bytes()) |
| 65 | cfg['schema'] = 1 |
| 66 | path.write_text('# preserved comment\n' + yaml.safe_dump(cfg)) |
| 67 | original = path.read_bytes() |
| 68 | docs = {p: (repo / p).read_bytes() for p in (v1.NEXT, 'docs/ROADMAP.md', 'docs/OVERSEER-HANDOVER.md')} |
| 69 | (repo / 'edited.txt').write_bytes(b'Uncommitted owner edit\x00') |
| 70 | before = fingerprint(repo) |
| 71 | args = adoption_args(repo) |
| 72 | r = command('-C', repo, 'adopt', *args, '--dry-run', '--json') |
| 73 | assert r.returncode == 0, r.stderr |
| 74 | assert fingerprint(repo) == before |
| 75 | plan = json.loads(r.stdout) |
| 76 | r = command('-C', repo, 'adopt', *args) |
| 77 | assert r.returncode == 0, r.stderr |
| 78 | assert (repo / plan['backup']).read_bytes() == original |
| 79 | assert status(repo)['repository']['id'] == cfg['repo']['id'] |
| 80 | assert all((repo / p).read_bytes() == data for p, data in docs.items()) |
| 81 | rollback = adoption_args(repo, vcs=None, restore_config=plan['backup']) |
| 82 | assert command('-C', repo, 'adopt', *rollback).returncode == 0 |
| 83 | assert path.read_bytes() == original |
| 84 | assert (repo / 'edited.txt').read_bytes() == b'Uncommitted owner edit\x00' |
| 85 | assert all((repo / p).read_bytes() == data for p, data in docs.items()) |
| 86 | assert command('-C', repo, 'next').returncode == 0 |
| 87 | |
| 88 | |
| 89 | @pytest.mark.parametrize('change', [dict(expect_config='0'*64), dict(repo_id='foreign'), |
| 90 | dict(lane='architecture'), dict(model='Other'), dict(branch='wrong'), dict(base_head='0'*40)]) |
| 91 | def test_adoption_expected_context_and_cas(repo, change): |
| 92 | before = fingerprint(repo) |
| 93 | r = command('-C', repo, 'adopt', *adoption_args(repo, **change)) |
| 94 | assert r.returncode != 0 |
| 95 | assert fingerprint(repo) == before |
| 96 | |
| 97 | |
| 98 | def test_tracked_local_files_are_reported_without_untracking(repo): |
| 99 | subprocess.run(['/usr/bin/git', '-C', str(repo), 'add', '-f', v1.NEXT], check=True) |
| 100 | before = fingerprint(repo) |
| 101 | r = command('-C', repo, 'adopt', *adoption_args(repo), '--dry-run', '--json') |
| 102 | assert r.returncode == 1 |
| 103 | assert json.loads(r.stdout)['tracked_local']['git'] == [v1.NEXT] |
| 104 | assert command('-C', repo, 'adopt', *adoption_args(repo)).returncode == 2 |
| 105 | assert fingerprint(repo) == before |
| 106 | assert v1.NEXT in v1.git(repo, 'ls-files') |
| 107 | |
| 108 | |
| 109 | def test_adoption_failure_before_config_keeps_old_binding(repo): |
| 110 | cfg, original = v1.config_for(repo, argparse.Namespace(bound_id=None)) |
| 111 | args = v1.parser().parse_args(['adopt', *adoption_args(repo)]) |
| 112 | real = v1.atomic |
| 113 | def fail_config(root, relative, *a, **kw): |
| 114 | if relative == v1.CONFIG: |
| 115 | raise OSError('injected before config replacement') |
| 116 | return real(root, relative, *a, **kw) |
| 117 | # Force an actual configuration delta. |
| 118 | cfg['schema'] = 1 |
| 119 | (repo / v1.CONFIG).write_bytes(v1.encode(cfg)) |
| 120 | original = (repo / v1.CONFIG).read_bytes() |
| 121 | args.expect_config = digest(original) |
| 122 | with patch('cli.v1.atomic', side_effect=fail_config), pytest.raises(OSError): |
| 123 | v1.adopt(repo, cfg, original, args) |
| 124 | assert (repo / v1.CONFIG).read_bytes() == original |
| 125 | assert command('-C', repo, 'next').returncode == 0 |
| 126 | |
| 127 | |
| 128 | def test_adoption_does_not_overwrite_concurrent_ignore_edit(repo): |
| 129 | cfg, original = v1.config_for(repo, argparse.Namespace(bound_id=None)) |
| 130 | args = v1.parser().parse_args(['adopt', *adoption_args(repo)]) |
| 131 | real = v1.ignore_assets |
| 132 | def edit_after_read(root, **kwargs): |
| 133 | mapping = real(root, **kwargs) |
| 134 | (root/'.gitignore').write_text('# concurrent owner edit\n') |
| 135 | return mapping |
| 136 | with patch('cli.v1.ignore_assets', side_effect=edit_after_read): |
| 137 | with pytest.raises(Refusal, match='stale_write'): |
| 138 | v1.adopt(repo, cfg, original, args) |
| 139 | assert (repo/'.gitignore').read_text() == '# concurrent owner edit\n' |
| 140 | assert (repo/v1.CONFIG).read_bytes() == original |
| 141 | |
| 142 | |
| 143 | def test_ignore_policy_preserves_rules_comments_and_templates(repo): |
| 144 | raw = b'# owner comment\n[global]\npatterns = ["old", "!docs/NEXT.md"]\n[domain.code]\npatterns = ["!docs/NEXT.md", "build/"]\n' |
| 145 | updated = muse_ignore(raw) |
| 146 | assert b'# owner comment' in updated and b'"old", "!docs/NEXT.md"' in updated |
| 147 | assert muse_ignore(updated) == updated |
| 148 | for path in ('templates/NEXT.template.md', 'docs/ROADMAP.md', 'docs/OVERSEER-HANDOVER.md'): |
| 149 | assert not v1.local_path(path) |
| 150 | |
| 151 | |
| 152 | @pytest.mark.parametrize('raw', [b'not valid toml', b'[global]\npatterns = "bad"', |
| 153 | b'[force_track]\npaths=["docs/NEXT.md"]']) |
| 154 | def test_invalid_or_forced_local_museignore_refuses_without_changes(repo, raw): |
| 155 | (repo / '.museignore').write_bytes(raw) |
| 156 | before = fingerprint(repo) |
| 157 | assert command('-C', repo, 'adopt', *adoption_args(repo)).returncode != 0 |
| 158 | assert fingerprint(repo) == before |
| 159 | |
| 160 | |
| 161 | @pytest.mark.parametrize('payload', [b'', b'not json', b'{}', b'[]', |
| 162 | b'{"root":"a","root":"b"}', b'x' * (2*1024*1024+1)]) |
| 163 | def test_bad_muse_response_has_no_git_fallback(tmp_path, payload): |
| 164 | venv = tmp_path / '.venv' |
| 165 | (venv / 'bin').mkdir(parents=True) |
| 166 | (venv / 'pyvenv.cfg').write_text('fixture') |
| 167 | with patch('cli.v1_revision.bounded_run', return_value=subprocess.CompletedProcess([], 0, payload, b'')): |
| 168 | with pytest.raises(Refusal, match='muse_'): |
| 169 | muse_read(tmp_path, str(venv / 'bin/python')) |
| 170 | |
| 171 | |
| 172 | @pytest.mark.parametrize('failure', [FileNotFoundError(), subprocess.TimeoutExpired('muse reader', 15)]) |
| 173 | def test_missing_and_timeout_muse_never_fall_back(tmp_path, failure): |
| 174 | venv = tmp_path / '.venv' |
| 175 | (venv / 'bin').mkdir(parents=True) |
| 176 | (venv / 'pyvenv.cfg').write_text('fixture') |
| 177 | with patch('cli.v1_revision.bounded_run', side_effect=failure): |
| 178 | with pytest.raises(type(failure)): |
| 179 | muse_read(tmp_path, str(venv / 'bin/python')) |
| 180 | |
| 181 | |
| 182 | @pytest.mark.parametrize('code,timeout,reason', [ |
| 183 | ('import time; time.sleep(30)', 0.1, 'timeout'), |
| 184 | ('import os; os.write(1, b"x" * 3000000)', 5, 'too_large')]) |
| 185 | def test_reader_process_output_and_time_are_bounded(tmp_path, code, timeout, reason): |
| 186 | import sys |
| 187 | from cli.v1_revision import bounded_run |
| 188 | with pytest.raises(Refusal, match=reason): |
| 189 | bounded_run([sys.executable, '-I', '-c', code], cwd=tmp_path, env={}, timeout=timeout) |
| 190 | |
| 191 | |
| 192 | def test_fresh_standalone_git_install_without_muse(tmp_path): |
| 193 | import shutil |
| 194 | import venv |
| 195 | from tests.v1.conftest import KIT |
| 196 | installation = tmp_path/'standalone-kit' |
| 197 | for relative in v1.RUNTIME_FILES: |
| 198 | target = installation/relative |
| 199 | target.parent.mkdir(parents=True, exist_ok=True) |
| 200 | shutil.copy2(KIT/relative, target) |
| 201 | venv.EnvBuilder(with_pip=False, symlinks=True).create(installation/'.venv') |
| 202 | python = installation/'.venv/bin/python3' |
| 203 | site = Path(subprocess.check_output([str(python), '-I', '-c', |
| 204 | 'import sysconfig; print(sysconfig.get_paths()["purelib"])'], text=True).strip()) |
| 205 | # Seed only the pinned runtime dependency offline; no editable finder or |
| 206 | # site packages from the development environment are inherited. |
| 207 | shutil.copytree(Path(yaml.__file__).parent, site/'yaml') |
| 208 | r = subprocess.run([str(python), '-I', '-c', |
| 209 | 'import importlib.util; assert importlib.util.find_spec("muse") is None'], capture_output=True) |
| 210 | assert r.returncode == 0 |
| 211 | root = seed(tmp_path/'standalone-project') |
| 212 | launcher = installation/'cli/ok' |
| 213 | for args in (('init',), ('status',), ('next',), ('sync',)): |
| 214 | r = command('-C', root, *args, executable=launcher, env={'PATH':'/usr/bin:/bin'}) |
| 215 | assert r.returncode == 0, r.stderr |
| 216 | assert yaml.safe_load((root/v1.CONFIG).read_bytes())['vcs'] == 'git' |
File History
1 commit
sha256:ff22b6a82a08c117176c3e2603264a0e49fe8204ce57c3cefe7f5b165a8634f8
docs: recenter public story on governance loop
Human
7 days ago