"""TDD — Phase 5: store.py linear walks become iter_ancestors wrappers. Phase 5 of issue #6 (generic DAG walker). ``store.py`` contains two linear first-parent walkers that predate ``graph.py``: - ``walk_commits_between_result`` — bounded range walk, returns WalkResult - ``get_commits_for_branch`` — branch HEAD walk with optional max_count Both use the same inline pattern: a ``while commit_id`` loop that follows ``parent_commit_id`` one step at a time. Neither uses ``iter_ancestors``. Fix: make both functions thin wrappers over ``iter_ancestors(root, start, first_parent_only=True)``. Behaviour must be identical — same commit order (newest-first), same ``from_commit_id`` stop condition, same ``max_commits`` / ``max_count`` cap, same ``truncated`` signalling. ``walk_commits_between`` is a one-liner wrapper over ``walk_commits_between_result`` — it stays as-is (no inline loop). Coverage -------- P5-1 Structural — ``walk_commits_between_result`` source contains ``iter_ancestors`` and no inline ``while commit_id`` loop P5-2 Structural — ``get_commits_for_branch`` source contains ``iter_ancestors`` and no inline ``while commit_id`` loop P5-3 Behavioural — ``walk_commits_between_result`` linear chain matches old behaviour: commits newest-first, stops before from_commit_id P5-4 Behavioural — ``walk_commits_between_result`` truncated flag fires at cap P5-5 Behavioural — ``walk_commits_between_result`` full walk (no from_commit) P5-6 Behavioural — ``get_commits_for_branch`` returns commits newest-first P5-7 Behavioural — ``get_commits_for_branch`` respects max_count """ from __future__ import annotations import datetime import inspect import json import pathlib import pytest from muse._version import __version__ from muse.core.object_store import write_object from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id from muse.core.commits import ( CommitRecord, write_commit, ) from muse.core.snapshots import ( SnapshotRecord, write_snapshot, ) from muse.core.types import blob_id from muse.core.paths import muse_dir # --------------------------------------------------------------------------- # Repo fixture helpers # --------------------------------------------------------------------------- def _repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: dot_muse = muse_dir(tmp_path) for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"): (dot_muse / d).mkdir(parents=True, exist_ok=True) (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") (dot_muse / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) return tmp_path def _write_obj(root: pathlib.Path, content: bytes) -> str: oid = blob_id(content) write_object(root, oid, content) return oid def _make_commit( root: pathlib.Path, manifest: dict[str, str], parent_id: str | None = None, *, message: str = "test", ) -> CommitRecord: snap_id = compute_snapshot_id(manifest) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest)) ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) cid = compute_commit_id( parent_ids=[parent_id] if parent_id else [], snapshot_id=snap_id, message=message, committed_at_iso=ts.isoformat(), ) rec = CommitRecord( commit_id=cid, branch="main", snapshot_id=snap_id, message=message, committed_at=ts, parent_commit_id=parent_id, ) write_commit(root, rec) return rec def _linear_chain(root: pathlib.Path, n: int) -> list[CommitRecord]: """Build a linear chain of n commits, return oldest-first.""" oid = _write_obj(root, b"data") commits: list[CommitRecord] = [] parent_id: str | None = None for i in range(n): c = _make_commit(root, {"f.py": oid}, parent_id, message=f"commit {i}") commits.append(c) parent_id = c.commit_id return commits # --------------------------------------------------------------------------- # P5-1 Structural — walk_commits_between_result uses iter_ancestors # --------------------------------------------------------------------------- def test_p5_1_walk_commits_between_result_uses_iter_ancestors() -> None: """walk_commits_between_result must delegate to iter_ancestors. The inline ``while commit_id`` pattern must not appear in the function body — it predates graph.py and is now replaced by iter_ancestors with first_parent_only=True. """ from muse.core import commits as store_mod src = inspect.getsource(store_mod.walk_commits_between_result) assert "iter_ancestors" in src, ( "walk_commits_between_result must delegate to iter_ancestors. " "Replace the inline while-loop with iter_ancestors(first_parent_only=True)." ) assert "while commit_id" not in src, ( "walk_commits_between_result still has an inline while-loop. " "Replace with iter_ancestors(first_parent_only=True)." ) # --------------------------------------------------------------------------- # P5-2 Structural — get_commits_for_branch uses iter_ancestors # --------------------------------------------------------------------------- def test_p5_2_get_commits_for_branch_uses_iter_ancestors() -> None: """get_commits_for_branch must delegate to iter_ancestors.""" from muse.core import commits as store_mod src = inspect.getsource(store_mod.get_commits_for_branch) assert "iter_ancestors" in src, ( "get_commits_for_branch must delegate to iter_ancestors. " "Replace the inline while-loop with iter_ancestors(first_parent_only=True)." ) assert "while commit_id" not in src, ( "get_commits_for_branch still has an inline while-loop. " "Replace with iter_ancestors(first_parent_only=True)." ) # --------------------------------------------------------------------------- # P5-3 Behavioural — walk_commits_between_result range stop # --------------------------------------------------------------------------- def test_p5_3_walk_commits_between_result_stops_before_from_commit( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """walk_commits_between_result returns commits from to_commit up to but not including from_commit, newest-first. Chain (oldest → newest): C1 → C2 → C3 → C4 Call: walk_commits_between_result(root, C4, from_commit_id=C1) Expected: [C4, C3, C2] — C1 is the exclusive lower bound. """ from muse.core.commits import walk_commits_between_result root = _repo(tmp_path, monkeypatch) chain = _linear_chain(root, 4) c1, c2, c3, c4 = chain result = walk_commits_between_result(root, c4.commit_id, from_commit_id=c1.commit_id) assert result["truncated"] is False ids = [c.commit_id for c in result["commits"]] assert ids == [c4.commit_id, c3.commit_id, c2.commit_id], ( f"Expected [C4, C3, C2] (C1 excluded), got {[i[:12] for i in ids]}" ) # --------------------------------------------------------------------------- # P5-4 Behavioural — walk_commits_between_result truncated flag # --------------------------------------------------------------------------- def test_p5_4_walk_commits_between_result_truncated_flag( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """truncated=True when max_commits is hit before the chain is exhausted.""" from muse.core.commits import walk_commits_between_result root = _repo(tmp_path, monkeypatch) chain = _linear_chain(root, 5) result = walk_commits_between_result(root, chain[-1].commit_id, max_commits=2) assert result["truncated"] is True assert len(result["commits"]) == 2 # --------------------------------------------------------------------------- # P5-5 Behavioural — walk_commits_between_result full walk (no from_commit) # --------------------------------------------------------------------------- def test_p5_5_walk_commits_between_result_full_walk( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """When from_commit_id is None, walk all the way to the initial commit.""" from muse.core.commits import walk_commits_between_result root = _repo(tmp_path, monkeypatch) chain = _linear_chain(root, 4) result = walk_commits_between_result(root, chain[-1].commit_id) assert result["truncated"] is False assert result["count"] == 4 ids = [c.commit_id for c in result["commits"]] expected = [c.commit_id for c in reversed(chain)] assert ids == expected, "Full walk must return all commits newest-first" # --------------------------------------------------------------------------- # P5-6 Behavioural — get_commits_for_branch returns newest-first # --------------------------------------------------------------------------- def test_p5_6_get_commits_for_branch_newest_first( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """get_commits_for_branch returns commits newest-first for a branch.""" from muse.core.commits import get_commits_for_branch root = _repo(tmp_path, monkeypatch) chain = _linear_chain(root, 3) # Point the branch ref at the tip. (muse_dir(root) / "refs" / "heads" / "main").write_text( chain[-1].commit_id + "\n" ) result = get_commits_for_branch(root, "main") ids = [c.commit_id for c in result] expected = [c.commit_id for c in reversed(chain)] assert ids == expected, "Commits must be newest-first" # --------------------------------------------------------------------------- # P5-7 Behavioural — get_commits_for_branch respects max_count # --------------------------------------------------------------------------- def test_p5_7_get_commits_for_branch_max_count( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """get_commits_for_branch stops after max_count commits.""" from muse.core.commits import get_commits_for_branch root = _repo(tmp_path, monkeypatch) chain = _linear_chain(root, 5) (muse_dir(root) / "refs" / "heads" / "main").write_text( chain[-1].commit_id + "\n" ) result = get_commits_for_branch(root, "main", max_count=2) assert len(result) == 2 assert result[0].commit_id == chain[-1].commit_id # newest first