test_adoption.py python
216 lines 9.7 KB
Raw
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