gabriel / muse public
test_release.py python
985 lines 40.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for the Muse release system.
2
3 Covers:
4 - Unit: parse_semver, semver_to_str, semver_channel, semver edge cases
5 - Unit: ReleaseRecord serialisation round-trip
6 - Unit: write_release, read_release, list_releases, delete_release, get_release_for_tag
7 - Unit: build_changelog from typed commit metadata
8 - Unit: WireTag in build_mpack / apply_mpack
9 - Integration: full release lifecycle (add → show → delete)
10 - E2E: muse release add / list / show / push / delete via CLI
11 """
12
13 from __future__ import annotations
14
15 import datetime
16 import json
17 import pathlib
18
19 import pytest
20 from tests.cli_test_helper import CliRunner
21
22 from muse.core.store import ReleaseRecord, ReleaseChannel
23 from muse.core._types import content_hash, Manifest
24 from muse.domain import SemVerBump
25
26 type _BumpMap = dict[str, SemVerBump]
27 type _ChannelMap = dict[str, ReleaseChannel]
28
29 runner = CliRunner()
30
31
32 # ---------------------------------------------------------------------------
33 # Repository scaffolding helpers
34 # ---------------------------------------------------------------------------
35
36
37 def _env(root: pathlib.Path) -> Manifest:
38 return {"MUSE_REPO_ROOT": str(root)}
39
40
41 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]:
42 muse_dir = tmp_path / ".muse"
43 muse_dir.mkdir()
44 repo_id = content_hash({"_seed": "test-1"})
45 (muse_dir / "repo.json").write_text(json.dumps({
46 "repo_id": repo_id,
47 "domain": domain,
48 "default_branch": "main",
49 "created_at": "2025-01-01T00:00:00+00:00",
50 }), encoding="utf-8")
51 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
52 (muse_dir / "refs" / "heads").mkdir(parents=True)
53 (muse_dir / "snapshots").mkdir()
54 (muse_dir / "commits").mkdir()
55 (muse_dir / "objects").mkdir()
56 return tmp_path, repo_id
57
58
59 def _make_commit(
60 root: pathlib.Path,
61 repo_id: str,
62 branch: str = "main",
63 message: str = "feat: add something",
64 sem_ver_bump: str = "minor",
65 breaking_changes: list[str] | None = None,
66 agent_id: str = "",
67 model_id: str = "",
68 ) -> str:
69 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
70 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
71
72 ref_file = root / ".muse" / "refs" / "heads" / branch
73 raw_parent = ref_file.read_text().strip() if ref_file.exists() else ""
74 parent_id: str | None = raw_parent if raw_parent else None
75 manifest: Manifest = {}
76 snap_id = compute_snapshot_id(manifest)
77 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
78 write_snapshot(root, snap)
79
80 now = datetime.datetime.now(datetime.timezone.utc)
81 parent_ids: list[str] = [parent_id] if parent_id else []
82 commit_id = compute_commit_id(
83 repo_id=repo_id,
84 parent_ids=parent_ids,
85 snapshot_id=snap_id,
86 message=message,
87 committed_at_iso=now.isoformat(),
88 )
89 _bump_map: _BumpMap = {"major": "major", "minor": "minor", "patch": "patch", "none": "none"}
90 bump_val: SemVerBump = _bump_map.get(sem_ver_bump, "none")
91
92 commit = CommitRecord(
93 commit_id=commit_id,
94 repo_id=repo_id,
95 created_on_branch=branch,
96 snapshot_id=snap_id,
97 message=message,
98 committed_at=now,
99 parent_commit_id=parent_id,
100 sem_ver_bump=bump_val,
101 breaking_changes=breaking_changes or [],
102 agent_id=agent_id,
103 model_id=model_id,
104 )
105 write_commit(root, commit)
106 ref_file.write_text(commit_id, encoding="utf-8")
107 return commit_id
108
109
110 # ---------------------------------------------------------------------------
111 # Semver parsing
112 # ---------------------------------------------------------------------------
113
114
115 class TestParseSemver:
116 def test_stable_version(self) -> None:
117 from muse.core.store import parse_semver
118
119 sv = parse_semver("v1.2.3")
120 assert sv["major"] == 1
121 assert sv["minor"] == 2
122 assert sv["patch"] == 3
123 assert sv["pre"] == ""
124 assert sv["build"] == ""
125
126 def test_no_v_prefix(self) -> None:
127 from muse.core.store import parse_semver
128
129 sv = parse_semver("2.0.0")
130 assert sv["major"] == 2
131
132 def test_pre_release(self) -> None:
133 from muse.core.store import parse_semver
134
135 sv = parse_semver("v1.3.0-beta.1")
136 assert sv["pre"] == "beta.1"
137
138 def test_alpha_pre_release(self) -> None:
139 from muse.core.store import parse_semver
140
141 sv = parse_semver("v0.5.0-alpha.2")
142 assert sv["pre"] == "alpha.2"
143
144 def test_build_metadata(self) -> None:
145 from muse.core.store import parse_semver
146
147 sv = parse_semver("v1.0.0+20240101")
148 assert sv["build"] == "20240101"
149 assert sv["pre"] == ""
150
151 def test_pre_and_build(self) -> None:
152 from muse.core.store import parse_semver
153
154 sv = parse_semver("v2.0.0-rc.1+build.42")
155 assert sv["pre"] == "rc.1"
156 assert sv["build"] == "build.42"
157
158 def test_invalid_raises(self) -> None:
159 from muse.core.store import parse_semver
160
161 with pytest.raises(ValueError, match="not valid semver"):
162 parse_semver("not-a-version")
163
164 def test_missing_patch_raises(self) -> None:
165 from muse.core.store import parse_semver
166
167 with pytest.raises(ValueError):
168 parse_semver("v1.2")
169
170 def test_leading_zero_minor_valid(self) -> None:
171 from muse.core.store import parse_semver
172
173 sv = parse_semver("v1.0.0")
174 assert sv["minor"] == 0
175
176
177 class TestSemverToStr:
178 def test_round_trip_stable(self) -> None:
179 from muse.core.store import SemVerTag, semver_to_str
180
181 sv = SemVerTag(major=1, minor=2, patch=3, pre="", build="")
182 assert semver_to_str(sv) == "v1.2.3"
183
184 def test_round_trip_prerelease(self) -> None:
185 from muse.core.store import SemVerTag, semver_to_str
186
187 sv = SemVerTag(major=1, minor=3, patch=0, pre="beta.1", build="")
188 assert semver_to_str(sv) == "v1.3.0-beta.1"
189
190 def test_round_trip_with_build(self) -> None:
191 from muse.core.store import SemVerTag, semver_to_str
192
193 sv = SemVerTag(major=2, minor=0, patch=0, pre="rc.1", build="42")
194 assert semver_to_str(sv) == "v2.0.0-rc.1+42"
195
196
197 class TestSemverChannel:
198 def test_stable_channel_no_pre(self) -> None:
199 from muse.core.store import SemVerTag, semver_channel
200
201 sv = SemVerTag(major=1, minor=0, patch=0, pre="", build="")
202 assert semver_channel(sv) == "stable"
203
204 def test_beta_channel(self) -> None:
205 from muse.core.store import SemVerTag, semver_channel
206
207 sv = SemVerTag(major=1, minor=0, patch=0, pre="beta.1", build="")
208 assert semver_channel(sv) == "beta"
209
210 def test_alpha_channel(self) -> None:
211 from muse.core.store import SemVerTag, semver_channel
212
213 sv = SemVerTag(major=1, minor=0, patch=0, pre="alpha.3", build="")
214 assert semver_channel(sv) == "alpha"
215
216 def test_nightly_channel(self) -> None:
217 from muse.core.store import SemVerTag, semver_channel
218
219 sv = SemVerTag(major=0, minor=0, patch=1, pre="nightly", build="")
220 assert semver_channel(sv) == "nightly"
221
222 def test_rc_defaults_to_stable(self) -> None:
223 from muse.core.store import SemVerTag, semver_channel
224
225 sv = SemVerTag(major=1, minor=0, patch=0, pre="rc.1", build="")
226 # rc is not a recognised channel prefix — defaults to stable
227 assert semver_channel(sv) == "stable"
228
229
230 # ---------------------------------------------------------------------------
231 # ReleaseRecord serialisation
232 # ---------------------------------------------------------------------------
233
234
235 class TestReleaseRecordSerialisation:
236 def _make_release(self) -> ReleaseRecord:
237 from muse.core.store import SemVerTag
238
239 return ReleaseRecord(
240 release_id=content_hash({"_seed": "test-2"}),
241 repo_id=content_hash({"_seed": "test-3"}),
242 tag="v1.0.0",
243 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
244 channel="stable",
245 commit_id="a" * 64,
246 snapshot_id="b" * 64,
247 title="First release",
248 body="Initial release notes.",
249 changelog=[],
250 )
251
252 def test_round_trip(self) -> None:
253 from muse.core.store import ReleaseRecord
254
255 release = self._make_release()
256 d = release.to_dict()
257 restored = ReleaseRecord.from_dict(d)
258 assert restored.release_id == release.release_id
259 assert restored.tag == release.tag
260 assert restored.semver == release.semver
261 assert restored.channel == release.channel
262 assert restored.title == release.title
263 assert restored.is_draft is False
264
265 def test_draft_round_trip(self) -> None:
266 from muse.core.store import ReleaseRecord
267
268 release = self._make_release()
269 release.is_draft = True
270 d = release.to_dict()
271 restored = ReleaseRecord.from_dict(d)
272 assert restored.is_draft is True
273
274 def test_invalid_channel_defaults_to_stable(self) -> None:
275 from muse.core.store import ReleaseRecord, SemVerTag
276
277 release = ReleaseRecord(
278 release_id=content_hash({"_seed": "test-4"}),
279 repo_id=content_hash({"_seed": "test-5"}),
280 tag="v1.0.0",
281 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
282 channel="stable",
283 commit_id="a" * 64,
284 snapshot_id="b" * 64,
285 title="",
286 body="",
287 changelog=[],
288 )
289 d = release.to_dict()
290 d["channel"] = "unknown-channel"
291 restored = ReleaseRecord.from_dict(d)
292 assert restored.channel == "stable"
293
294
295 # ---------------------------------------------------------------------------
296 # Release store operations
297 # ---------------------------------------------------------------------------
298
299
300 class TestReleaseStore:
301 def test_write_and_read(self, tmp_path: pathlib.Path) -> None:
302 from muse.core.store import ReleaseRecord, SemVerTag, read_release, write_release
303
304 root, repo_id = _init_repo(tmp_path)
305 release = ReleaseRecord(
306 release_id=content_hash({"_seed": "test-6"}),
307 repo_id=repo_id,
308 tag="v1.0.0",
309 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
310 channel="stable",
311 commit_id="a" * 64,
312 snapshot_id="b" * 64,
313 title="v1.0.0",
314 body="",
315 changelog=[],
316 )
317 write_release(root, release)
318 loaded = read_release(root, repo_id, release.release_id)
319 assert loaded is not None
320 assert loaded.tag == "v1.0.0"
321
322 def test_read_missing_returns_none(self, tmp_path: pathlib.Path) -> None:
323 from muse.core.store import read_release
324
325 root, repo_id = _init_repo(tmp_path)
326 assert read_release(root, repo_id, content_hash({"_seed": "test-7"})) is None
327
328 def test_list_releases_newest_first(self, tmp_path: pathlib.Path) -> None:
329 from muse.core.store import ReleaseRecord, SemVerTag, list_releases, write_release
330 import time
331
332 root, repo_id = _init_repo(tmp_path)
333
334 for i, tag in enumerate(["v1.0.0", "v1.1.0", "v1.2.0"]):
335 sv = SemVerTag(major=1, minor=i, patch=0, pre="", build="")
336 r = ReleaseRecord(
337 release_id=content_hash({"_seed": f"test-8-{i}"}),
338 repo_id=repo_id,
339 tag=tag,
340 semver=sv,
341 channel="stable",
342 commit_id="a" * 64,
343 snapshot_id="b" * 64,
344 title=tag,
345 body="",
346 changelog=[],
347 created_at=datetime.datetime(2025, 1, i + 1, tzinfo=datetime.timezone.utc),
348 )
349 write_release(root, r)
350 time.sleep(0.01) # ensure distinct timestamps
351
352 releases = list_releases(root, repo_id)
353 assert len(releases) == 3
354 # newest first
355 assert releases[0].tag == "v1.2.0"
356 assert releases[-1].tag == "v1.0.0"
357
358 def test_list_excludes_drafts_by_default(self, tmp_path: pathlib.Path) -> None:
359 from muse.core.store import ReleaseRecord, SemVerTag, list_releases, write_release
360
361 root, repo_id = _init_repo(tmp_path)
362 for j, (tag, draft) in enumerate([("v1.0.0", False), ("v1.1.0-beta.1", True)]):
363 sv_parts = tag.lstrip("v").split("-")
364 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
365 pre = sv_parts[1] if len(sv_parts) > 1 else ""
366 r = ReleaseRecord(
367 release_id=content_hash({"_seed": f"test-9-{j}"}),
368 repo_id=repo_id,
369 tag=tag,
370 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
371 channel="stable" if not pre else "beta",
372 commit_id="a" * 64,
373 snapshot_id="b" * 64,
374 title=tag,
375 body="",
376 changelog=[],
377 is_draft=draft,
378 )
379 write_release(root, r)
380
381 assert len(list_releases(root, repo_id)) == 1
382 assert len(list_releases(root, repo_id, include_drafts=True)) == 2
383
384 def test_filter_by_channel(self, tmp_path: pathlib.Path) -> None:
385 from muse.core.store import ReleaseChannel, ReleaseRecord, SemVerTag, list_releases, write_release
386
387 _ch_map: _ChannelMap = {"stable": "stable", "beta": "beta", "alpha": "alpha", "nightly": "nightly"}
388 root, repo_id = _init_repo(tmp_path)
389 for k, (tag, channel_str) in enumerate([("v1.0.0", "stable"), ("v1.1.0-beta.1", "beta"), ("v1.2.0", "stable")]):
390 sv_parts = tag.lstrip("v").split("-")
391 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
392 pre = sv_parts[1] if len(sv_parts) > 1 else ""
393 r = ReleaseRecord(
394 release_id=content_hash({"_seed": f"test-10-{k}"}),
395 repo_id=repo_id,
396 tag=tag,
397 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
398 channel=_ch_map.get(channel_str, "stable"),
399 commit_id="a" * 64,
400 snapshot_id="b" * 64,
401 title=tag,
402 body="",
403 changelog=[],
404 )
405 write_release(root, r)
406
407 stable = list_releases(root, repo_id, channel="stable")
408 beta = list_releases(root, repo_id, channel="beta")
409 assert len(stable) == 2
410 assert len(beta) == 1
411
412 def test_delete_release(self, tmp_path: pathlib.Path) -> None:
413 from muse.core.store import ReleaseRecord, SemVerTag, delete_release, list_releases, write_release
414
415 root, repo_id = _init_repo(tmp_path)
416 release_id = content_hash({"_seed": "test-11"})
417 r = ReleaseRecord(
418 release_id=release_id,
419 repo_id=repo_id,
420 tag="v0.1.0",
421 semver=SemVerTag(major=0, minor=1, patch=0, pre="", build=""),
422 channel="stable",
423 commit_id="a" * 64,
424 snapshot_id="b" * 64,
425 title="",
426 body="",
427 changelog=[],
428 )
429 write_release(root, r)
430 assert len(list_releases(root, repo_id)) == 1
431 assert delete_release(root, repo_id, release_id) is True
432 assert len(list_releases(root, repo_id)) == 0
433
434 def test_delete_nonexistent_returns_false(self, tmp_path: pathlib.Path) -> None:
435 from muse.core.store import delete_release
436
437 root, repo_id = _init_repo(tmp_path)
438 assert delete_release(root, repo_id, content_hash({"_seed": "test-12"})) is False
439
440 def test_get_release_for_tag(self, tmp_path: pathlib.Path) -> None:
441 from muse.core.store import ReleaseRecord, SemVerTag, get_release_for_tag, write_release
442
443 root, repo_id = _init_repo(tmp_path)
444 r = ReleaseRecord(
445 release_id=content_hash({"_seed": "test-13"}),
446 repo_id=repo_id,
447 tag="v2.0.0",
448 semver=SemVerTag(major=2, minor=0, patch=0, pre="", build=""),
449 channel="stable",
450 commit_id="a" * 64,
451 snapshot_id="b" * 64,
452 title="",
453 body="",
454 changelog=[],
455 )
456 write_release(root, r)
457 assert get_release_for_tag(root, repo_id, "v2.0.0") is not None
458 assert get_release_for_tag(root, repo_id, "v9.9.9") is None
459
460
461 # ---------------------------------------------------------------------------
462 # build_changelog
463 # ---------------------------------------------------------------------------
464
465
466 class TestBuildChangelog:
467 def test_changelog_from_commits(self, tmp_path: pathlib.Path) -> None:
468 from muse.core.store import build_changelog
469
470 root, repo_id = _init_repo(tmp_path)
471 c1 = _make_commit(root, repo_id, message="feat: first", sem_ver_bump="minor")
472 c2 = _make_commit(root, repo_id, message="fix: patch fix", sem_ver_bump="patch")
473 c3 = _make_commit(root, repo_id, message="feat!: breaking", sem_ver_bump="major",
474 breaking_changes=["API changed"])
475
476 changelog = build_changelog(root, None, c3)
477 assert len(changelog) == 3
478 assert changelog[0]["commit_id"] == c1 # oldest first
479 assert changelog[2]["sem_ver_bump"] == "major"
480 assert changelog[2]["breaking_changes"] == ["API changed"]
481
482 def test_changelog_bounded_by_from_commit(self, tmp_path: pathlib.Path) -> None:
483 from muse.core.store import build_changelog
484
485 root, repo_id = _init_repo(tmp_path)
486 c1 = _make_commit(root, repo_id, message="chore: setup", sem_ver_bump="none")
487 c2 = _make_commit(root, repo_id, message="feat: add feature", sem_ver_bump="minor")
488 c3 = _make_commit(root, repo_id, message="fix: tiny fix", sem_ver_bump="patch")
489
490 # Only c2 and c3 are since c1
491 changelog = build_changelog(root, c1, c3)
492 assert len(changelog) == 2
493 assert changelog[0]["commit_id"] == c2
494
495 def test_empty_changelog_same_commit(self, tmp_path: pathlib.Path) -> None:
496 from muse.core.store import build_changelog
497
498 root, repo_id = _init_repo(tmp_path)
499 c1 = _make_commit(root, repo_id)
500 changelog = build_changelog(root, c1, c1)
501 assert changelog == []
502
503 def test_changelog_includes_agent_provenance(self, tmp_path: pathlib.Path) -> None:
504 from muse.core.store import build_changelog
505
506 root, repo_id = _init_repo(tmp_path)
507 _make_commit(root, repo_id, message="feat: add", sem_ver_bump="minor",
508 agent_id="my-agent", model_id="claude-4")
509 head_commit = _make_commit(root, repo_id, message="fix: patch", sem_ver_bump="patch")
510 changelog = build_changelog(root, None, head_commit)
511 assert changelog[0]["agent_id"] == "my-agent"
512 assert changelog[0]["model_id"] == "claude-4"
513
514
515 # ---------------------------------------------------------------------------
516 # WireTag in pack
517 # ---------------------------------------------------------------------------
518
519
520 class TestWireTagInPack:
521 def test_build_pack_includes_tags(self, tmp_path: pathlib.Path) -> None:
522 from muse.core.pack import build_mpack
523 from muse.core.store import TagRecord, write_tag
524
525 root, repo_id = _init_repo(tmp_path)
526 commit_id = _make_commit(root, repo_id)
527
528 tag = TagRecord(
529 tag_id=content_hash({"_seed": "test-14"}),
530 repo_id=repo_id,
531 commit_id=commit_id,
532 tag="v1.0.0",
533 )
534 write_tag(root, tag)
535
536 bundle = build_mpack(root, [commit_id], repo_id=repo_id)
537 assert "tags" in bundle
538 tags = bundle["tags"]
539 assert len(tags) == 1
540 assert tags[0]["tag"] == "v1.0.0"
541 assert tags[0]["commit_id"] == commit_id
542
543 def test_build_pack_no_tags_when_repo_id_omitted(self, tmp_path: pathlib.Path) -> None:
544 from muse.core.pack import build_mpack
545 from muse.core.store import TagRecord, write_tag
546
547 root, repo_id = _init_repo(tmp_path)
548 commit_id = _make_commit(root, repo_id)
549 write_tag(root, TagRecord(
550 tag_id=content_hash({"_seed": "test-15"}),
551 repo_id=repo_id,
552 commit_id=commit_id,
553 tag="v1.0.0",
554 ))
555
556 bundle = build_mpack(root, [commit_id]) # no repo_id
557 assert "tags" not in bundle
558
559 def test_apply_pack_writes_tags(self, tmp_path: pathlib.Path) -> None:
560 from muse.core.pack import apply_mpack, build_mpack, WireTag
561 from muse.core.store import TagRecord, get_all_tags, write_tag
562
563 src = tmp_path / "src"
564 dst = tmp_path / "dst"
565 src.mkdir()
566 dst.mkdir()
567
568 root_src, repo_id = _init_repo(src)
569 root_dst, _ = _init_repo(dst)
570
571 commit_id = _make_commit(root_src, repo_id)
572 write_tag(root_src, TagRecord(
573 tag_id=content_hash({"_seed": "test-16"}),
574 repo_id=repo_id,
575 commit_id=commit_id,
576 tag="v1.0.0",
577 ))
578
579 bundle = build_mpack(root_src, [commit_id], repo_id=repo_id)
580 apply_mpack(root_dst, bundle)
581
582 tags = get_all_tags(root_dst, repo_id)
583 assert any(t.tag == "v1.0.0" for t in tags)
584
585
586 # ---------------------------------------------------------------------------
587 # E2E CLI: muse release
588 # ---------------------------------------------------------------------------
589
590
591 class TestReleaseCLI:
592 def test_release_add_basic(self, tmp_path: pathlib.Path) -> None:
593 root, repo_id = _init_repo(tmp_path)
594 _make_commit(root, repo_id, message="feat: initial", sem_ver_bump="minor")
595
596 result = runner.invoke(None, ["release", "add", "v0.1.0", "--title", "First"], env=_env(root))
597 assert result.exit_code == 0, result.output
598 assert "v0.1.0" in result.output
599
600 def test_release_add_invalid_semver(self, tmp_path: pathlib.Path) -> None:
601 root, repo_id = _init_repo(tmp_path)
602 _make_commit(root, repo_id)
603
604 result = runner.invoke(None, ["release", "add", "not-valid"], env=_env(root))
605 assert result.exit_code != 0
606
607 def test_release_add_duplicate_rejected(self, tmp_path: pathlib.Path) -> None:
608 root, repo_id = _init_repo(tmp_path)
609 _make_commit(root, repo_id)
610
611 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
612 result = runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
613 assert result.exit_code != 0
614 assert "already exists" in result.output.lower() or "already exists" in result.stderr.lower() if hasattr(result, 'stderr') else True
615
616 def test_release_add_draft(self, tmp_path: pathlib.Path) -> None:
617 root, repo_id = _init_repo(tmp_path)
618 _make_commit(root, repo_id)
619
620 result = runner.invoke(None, ["release", "add", "v1.0.0-beta.1", "--draft"], env=_env(root))
621 assert result.exit_code == 0
622 assert "draft" in result.output.lower()
623
624 def test_release_add_json_output(self, tmp_path: pathlib.Path) -> None:
625 root, repo_id = _init_repo(tmp_path)
626 _make_commit(root, repo_id)
627
628 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
629 assert result.exit_code == 0, result.output
630 data = json.loads(result.output)
631 assert data["tag"] == "v1.0.0"
632 assert data["channel"] == "stable"
633 assert "release_id" in data
634
635 def test_release_list(self, tmp_path: pathlib.Path) -> None:
636 root, repo_id = _init_repo(tmp_path)
637 _make_commit(root, repo_id)
638 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
639
640 result = runner.invoke(None, ["release", "list"], env=_env(root))
641 assert result.exit_code == 0
642 assert "v1.0.0" in result.output
643
644 def test_release_list_empty(self, tmp_path: pathlib.Path) -> None:
645 root, repo_id = _init_repo(tmp_path)
646 result = runner.invoke(None, ["release", "list"], env=_env(root))
647 assert result.exit_code == 0
648 assert "No releases" in result.output
649
650 def test_release_list_json(self, tmp_path: pathlib.Path) -> None:
651 root, repo_id = _init_repo(tmp_path)
652 _make_commit(root, repo_id)
653 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
654
655 result = runner.invoke(None, ["release", "list", "--json"], env=_env(root))
656 assert result.exit_code == 0, result.output
657 data = json.loads(result.output)
658 assert isinstance(data, dict)
659 assert data["total"] == 1
660 assert data["releases"][0]["tag"] == "v1.0.0"
661
662 def test_release_read(self, tmp_path: pathlib.Path) -> None:
663 root, repo_id = _init_repo(tmp_path)
664 _make_commit(root, repo_id)
665 runner.invoke(None, ["release", "add", "v1.0.0", "--title", "Production"], env=_env(root))
666
667 result = runner.invoke(None, ["release", "read", "v1.0.0"], env=_env(root))
668 assert result.exit_code == 0
669 assert "v1.0.0" in result.output
670 assert "stable" in result.output
671
672 def test_release_read_not_found(self, tmp_path: pathlib.Path) -> None:
673 root, repo_id = _init_repo(tmp_path)
674 result = runner.invoke(None, ["release", "read", "v99.99.99"], env=_env(root))
675 assert result.exit_code != 0
676
677 def test_release_delete_draft(self, tmp_path: pathlib.Path) -> None:
678 root, repo_id = _init_repo(tmp_path)
679 _make_commit(root, repo_id)
680 runner.invoke(None, ["release", "add", "v1.0.0-alpha.1", "--draft"], env=_env(root))
681
682 result = runner.invoke(None, ["release", "delete", "v1.0.0-alpha.1", "--yes"], env=_env(root))
683 assert result.exit_code == 0
684 assert "deleted" in result.output.lower()
685
686 def test_release_delete_published_with_yes(self, tmp_path: pathlib.Path) -> None:
687 """Published releases can be deleted when --yes bypasses the tag-name prompt."""
688 root, repo_id = _init_repo(tmp_path)
689 _make_commit(root, repo_id)
690 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
691
692 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
693 assert result.exit_code == 0
694 assert "deleted" in result.output.lower()
695
696 # Confirm it's gone from the list.
697 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
698 assert "v1.0.0" not in list_result.output
699
700 def test_release_delete_published_non_tty_requires_yes(self, tmp_path: pathlib.Path) -> None:
701 """Non-TTY context without --yes must exit with USER_ERROR (no blocking input())."""
702 root, repo_id = _init_repo(tmp_path)
703 _make_commit(root, repo_id)
704 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
705
706 # CliRunner is non-TTY — without --yes, the guard fires before input() is called.
707 result = runner.invoke(None, ["release", "delete", "v1.0.0"], env=_env(root))
708 assert result.exit_code != 0
709 assert "TTY" in result.output or "--yes" in result.output
710
711 # Release must still exist.
712 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
713 assert "v1.0.0" in list_result.output
714
715 def test_release_delete_published_with_yes_flag(self, tmp_path: pathlib.Path) -> None:
716 """Passing --yes skips the interactive confirmation in non-TTY contexts."""
717 root, repo_id = _init_repo(tmp_path)
718 _make_commit(root, repo_id)
719 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
720
721 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
722 assert result.exit_code == 0
723 assert "deleted" in result.output.lower()
724
725 def test_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
726 root, repo_id = _init_repo(tmp_path)
727 _make_commit(root, repo_id)
728 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
729 _make_commit(root, repo_id)
730 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
731
732 result = runner.invoke(None, ["release", "list", "--channel", "stable"], env=_env(root))
733 assert result.exit_code == 0
734 assert "v1.0.0" in result.output
735 assert "v1.1.0" not in result.output
736
737 def test_release_changelog_in_json_output(self, tmp_path: pathlib.Path) -> None:
738 root, repo_id = _init_repo(tmp_path)
739 _make_commit(root, repo_id, message="feat: add API", sem_ver_bump="minor")
740 _make_commit(root, repo_id, message="fix: handle edge case", sem_ver_bump="patch")
741
742 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
743 assert result.exit_code == 0, result.output
744 data = json.loads(result.output)
745 assert len(data["changelog"]) == 2
746 assert data["changelog"][0]["sem_ver_bump"] == "minor"
747
748
749 # ---------------------------------------------------------------------------
750 # muse release (no subcommand) — defaults to list
751 # ---------------------------------------------------------------------------
752
753
754 class TestReleaseDefaultToList:
755 """Bare ``muse release`` with no subcommand should behave like ``muse release list``."""
756
757 def test_bare_release_exits_zero(self, tmp_path: pathlib.Path) -> None:
758 root, repo_id = _init_repo(tmp_path)
759 _make_commit(root, repo_id)
760 result = runner.invoke(None, ["release"], env=_env(root))
761 assert result.exit_code == 0
762
763 def test_bare_release_empty_repo_prints_no_releases(self, tmp_path: pathlib.Path) -> None:
764 root, _ = _init_repo(tmp_path)
765 result = runner.invoke(None, ["release"], env=_env(root))
766 assert result.exit_code == 0
767 assert "No releases" in result.output
768
769 def test_bare_release_lists_existing_releases(self, tmp_path: pathlib.Path) -> None:
770 root, repo_id = _init_repo(tmp_path)
771 _make_commit(root, repo_id)
772 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
773 result = runner.invoke(None, ["release"], env=_env(root))
774 assert result.exit_code == 0
775 assert "v1.0.0" in result.output
776
777 def test_bare_release_json_flag(self, tmp_path: pathlib.Path) -> None:
778 """``muse release --json`` must emit a JSON dict with total and releases keys."""
779 root, repo_id = _init_repo(tmp_path)
780 _make_commit(root, repo_id)
781 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
782 result = runner.invoke(None, ["release", "--json"], env=_env(root))
783 assert result.exit_code == 0, result.output
784 data = json.loads(result.output)
785 assert isinstance(data, dict)
786 assert data["total"] == 1
787 assert data["releases"][0]["tag"] == "v1.0.0"
788
789 def test_bare_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
790 root, repo_id = _init_repo(tmp_path)
791 _make_commit(root, repo_id)
792 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
793 _make_commit(root, repo_id)
794 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
795 result = runner.invoke(None, ["release", "--channel", "stable"], env=_env(root))
796 assert result.exit_code == 0
797 assert "v1.0.0" in result.output
798 assert "v1.1.0" not in result.output
799
800
801 # ---------------------------------------------------------------------------
802 # muse release suggest
803 # ---------------------------------------------------------------------------
804
805
806 class TestReleaseSuggest:
807 """``muse release suggest`` derives the next version from the commit graph."""
808
809 def test_no_commits_no_releases(self, tmp_path: pathlib.Path) -> None:
810 """Repo with no commits should exit with an error (no HEAD)."""
811 root, _ = _init_repo(tmp_path)
812 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
813 assert result.exit_code != 0
814
815 def test_no_releases_all_none_bumps(self, tmp_path: pathlib.Path) -> None:
816 """All commits carry bump=none → no suggestion, exit 0."""
817 root, repo_id = _init_repo(tmp_path)
818 _make_commit(root, repo_id, message="chore: housekeeping", sem_ver_bump="none")
819 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
820 assert result.exit_code == 0
821 assert "no version bump" in result.output.lower() or "none" in result.output.lower()
822
823 def test_no_releases_patch_bump(self, tmp_path: pathlib.Path) -> None:
824 """patch bump with no prior release → v0.0.1."""
825 root, repo_id = _init_repo(tmp_path)
826 _make_commit(root, repo_id, message="fix: typo", sem_ver_bump="patch")
827 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
828 assert result.exit_code == 0
829 data = json.loads(result.output)
830 assert data["suggested_tag"] == "v0.0.1"
831 assert data["inferred_bump"] == "patch"
832 assert data["pre_1_0_adjusted"] is True
833
834 def test_no_releases_minor_bump(self, tmp_path: pathlib.Path) -> None:
835 """minor bump with no prior release in pre-1.0 → v0.0.1."""
836 root, repo_id = _init_repo(tmp_path)
837 _make_commit(root, repo_id, message="feat: new helper", sem_ver_bump="minor")
838 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
839 assert result.exit_code == 0
840 data = json.loads(result.output)
841 assert data["suggested_tag"] == "v0.0.1"
842 assert data["inferred_bump"] == "minor"
843 assert data["pre_1_0_adjusted"] is True
844
845 def test_no_releases_major_bump(self, tmp_path: pathlib.Path) -> None:
846 """major bump in pre-1.0 repo with no prior release → bumps minor → v0.1.0."""
847 root, repo_id = _init_repo(tmp_path)
848 _make_commit(root, repo_id, message="feat!: breaking change",
849 sem_ver_bump="major", breaking_changes=["api.py::MyClass"])
850 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
851 assert result.exit_code == 0
852 data = json.loads(result.output)
853 assert data["suggested_tag"] == "v0.1.0"
854 assert data["inferred_bump"] == "major"
855 assert data["pre_1_0_adjusted"] is True
856
857 def test_takes_highest_bump_across_commits(self, tmp_path: pathlib.Path) -> None:
858 """max(patch, minor, major) = major drives the suggestion."""
859 root, repo_id = _init_repo(tmp_path)
860 _make_commit(root, repo_id, message="fix: small", sem_ver_bump="patch")
861 _make_commit(root, repo_id, message="feat: new", sem_ver_bump="minor")
862 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
863 breaking_changes=["core.py::Fn"])
864 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
865 assert result.exit_code == 0
866 data = json.loads(result.output)
867 assert data["inferred_bump"] == "major"
868
869 def test_with_prior_release_patch(self, tmp_path: pathlib.Path) -> None:
870 """patch since v1.2.3 → v1.2.4 (post-1.0, no adjustment)."""
871 root, repo_id = _init_repo(tmp_path)
872 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
873 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
874 _make_commit(root, repo_id, message="fix: edge case", sem_ver_bump="patch")
875 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
876 assert result.exit_code == 0
877 data = json.loads(result.output)
878 assert data["suggested_tag"] == "v1.2.4"
879 assert data["pre_1_0_adjusted"] is False
880 assert data["base_tag"] == "v1.2.3"
881
882 def test_with_prior_release_minor(self, tmp_path: pathlib.Path) -> None:
883 """minor since v1.2.3 → v1.3.0."""
884 root, repo_id = _init_repo(tmp_path)
885 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
886 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
887 _make_commit(root, repo_id, message="feat: new thing", sem_ver_bump="minor")
888 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
889 assert result.exit_code == 0
890 data = json.loads(result.output)
891 assert data["suggested_tag"] == "v1.3.0"
892
893 def test_with_prior_release_major(self, tmp_path: pathlib.Path) -> None:
894 """major since v1.2.3 → v2.0.0."""
895 root, repo_id = _init_repo(tmp_path)
896 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
897 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
898 _make_commit(root, repo_id, message="feat!: overhaul", sem_ver_bump="major",
899 breaking_changes=["core.py::API"])
900 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
901 assert result.exit_code == 0
902 data = json.loads(result.output)
903 assert data["suggested_tag"] == "v2.0.0"
904 assert data["pre_1_0_adjusted"] is False
905
906 def test_pre_1_0_major_bumps_minor(self, tmp_path: pathlib.Path) -> None:
907 """In 0.x.y, major structural break bumps minor not major (0.x+1.0)."""
908 root, repo_id = _init_repo(tmp_path)
909 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
910 runner.invoke(None, ["release", "add", "v0.2.5"], env=_env(root))
911 _make_commit(root, repo_id, message="feat!: break API", sem_ver_bump="major",
912 breaking_changes=["api.py::Client"])
913 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
914 assert result.exit_code == 0
915 data = json.loads(result.output)
916 assert data["suggested_tag"] == "v0.3.0"
917 assert data["pre_1_0_adjusted"] is True
918
919 def test_drivers_list_contains_bumping_commits(self, tmp_path: pathlib.Path) -> None:
920 """drivers list includes only commits with bump != none."""
921 root, repo_id = _init_repo(tmp_path)
922 _make_commit(root, repo_id, message="chore: no-op", sem_ver_bump="none")
923 _make_commit(root, repo_id, message="fix: something", sem_ver_bump="patch")
924 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
925 assert result.exit_code == 0
926 data = json.loads(result.output)
927 assert data["unreleased_count"] == 2
928 assert len(data["drivers"]) == 1
929 assert data["drivers"][0]["sem_ver_bump"] == "patch"
930
931 def test_unreleased_count_excludes_released_commits(self, tmp_path: pathlib.Path) -> None:
932 """Commits before the base release are not counted."""
933 root, repo_id = _init_repo(tmp_path)
934 _make_commit(root, repo_id, message="old feat", sem_ver_bump="major")
935 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
936 _make_commit(root, repo_id, message="new fix", sem_ver_bump="patch")
937 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
938 assert result.exit_code == 0
939 data = json.loads(result.output)
940 assert data["unreleased_count"] == 1
941
942 def test_base_flag_overrides_latest_release(self, tmp_path: pathlib.Path) -> None:
943 """--base selects a specific release as the starting point."""
944 root, repo_id = _init_repo(tmp_path)
945 _make_commit(root, repo_id, message="v1 base", sem_ver_bump="none")
946 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
947 _make_commit(root, repo_id, message="patch after v1", sem_ver_bump="patch")
948 runner.invoke(None, ["release", "add", "v1.0.1"], env=_env(root))
949 _make_commit(root, repo_id, message="feature after v1.0.1", sem_ver_bump="minor")
950
951 result = runner.invoke(None, ["release", "suggest", "--base", "v1.0.0", "--json"],
952 env=_env(root))
953 assert result.exit_code == 0
954 data = json.loads(result.output)
955 # Since v1.0.0: one patch + one minor → minor bump → v1.1.0
956 assert data["suggested_tag"] == "v1.1.0"
957 assert data["base_tag"] == "v1.0.0"
958
959 def test_base_flag_not_found(self, tmp_path: pathlib.Path) -> None:
960 """--base with unknown tag exits with NOT_FOUND."""
961 root, repo_id = _init_repo(tmp_path)
962 _make_commit(root, repo_id)
963 result = runner.invoke(None, ["release", "suggest", "--base", "v99.0.0"], env=_env(root))
964 assert result.exit_code != 0
965
966 def test_json_schema_keys_present(self, tmp_path: pathlib.Path) -> None:
967 """JSON output always contains all expected keys."""
968 root, repo_id = _init_repo(tmp_path)
969 _make_commit(root, repo_id, message="feat: something", sem_ver_bump="minor")
970 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
971 assert result.exit_code == 0
972 data = json.loads(result.output)
973 for key in ("suggested_tag", "inferred_bump", "pre_1_0_adjusted",
974 "base_tag", "base_commit_id", "head_commit_id",
975 "unreleased_count", "drivers"):
976 assert key in data, f"missing key: {key}"
977
978 def test_text_output_contains_suggested_tag(self, tmp_path: pathlib.Path) -> None:
979 """Human-readable output includes the suggested tag."""
980 root, repo_id = _init_repo(tmp_path)
981 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
982 breaking_changes=["mod.py::Fn"])
983 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
984 assert result.exit_code == 0
985 assert "v0.1.0" in result.output
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago