"""TDD — merge/revert/cherry-pick/pull/rebase commits must carry author attribution. Bug --- Several commands that create new commits omit the ``author`` field from both ``hash_commit()`` and the resulting ``CommitRecord``. The hash covers ``author=""`` while the intent is to record who performed the operation. Any later call to ``_verify_commit_id`` that uses the stored (empty) author field stays consistent, but the data is semantically wrong — the commit is anonymous. The fix for each command is the same: 1. Read ``user.handle`` from config (``get_config_value("user.handle", root)``). 2. Pass it as ``author`` to ``hash_commit()`` so the hash covers it. 3. Pass the same value to ``CommitRecord(author=...)`` so hash and stored field always agree. Affected commands ----------------- A1 ``muse merge`` — merge commit should be authored by the merger A2 ``muse revert`` — revert commit should be authored by the reverter A3 ``muse cherry-pick`` — preserves original commit's author A4 ``muse pull`` (merge) — auto-merge commit should be authored by the puller A5 ``muse rebase --squash`` — squash commit preserves first original author """ from __future__ import annotations import datetime import json import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core.paths import heads_dir, ref_path, muse_dir from muse.core.types import fake_id, blob_id, Manifest from muse.core.workdir import apply_manifest cli = None runner = CliRunner() _HANDLE = "gabriel" # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- type _Env = dict[str, str] def _env(root: pathlib.Path) -> _Env: return {"MUSE_REPO_ROOT": str(root)} def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() repo_id = fake_id("repo") (dot_muse / "repo.json").write_text(json.dumps({ "repo_id": repo_id, "domain": "code", "default_branch": "main", "created_at": "2025-01-01T00:00:00+00:00", }), encoding="utf-8") (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "snapshots").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "objects").mkdir() return tmp_path, repo_id _HUB_URL = "https://localhost:1337" _HUB_URL = "https://localhost:1337" def _set_user_handle(root: pathlib.Path, handle: str) -> None: """Wire up a minimal identity so get_config_value("user.handle", root) returns handle.""" from muse.core.identity import save_identity from muse.cli.config import set_hub_url # Point repo config at the fake hub so get_config_value can resolve the hostname. set_hub_url(_HUB_URL, root) # Write a minimal identity entry to the (already-patched-to-tmp) identity.toml. save_identity(_HUB_URL, { "type": "human", "handle": handle, "algorithm": "ed25519", "fingerprint": "0" * 64, "capabilities": [], "provisioned_by": "", "hd_path": "", "provisioned_by_fingerprint": "", }) def _current_head_branch(root: pathlib.Path) -> str: head = (muse_dir(root) / "HEAD").read_text().strip() if head.startswith("ref: refs/heads/"): return head[len("ref: refs/heads/"):] return "" def _make_commit( root: pathlib.Path, repo_id: str, branch: str = "main", message: str = "test", manifest: dict[str, str] | None = None, author: str = "", prev_manifest: Manifest | None = None, ) -> str: from muse.core.commits import ( CommitRecord, write_commit, ) from muse.core.snapshots import ( SnapshotRecord, write_snapshot, ) from muse.core.ids import hash_commit, hash_snapshot ref_file = ref_path(root, branch) parent_id = ref_file.read_text().strip() if ref_file.exists() else None m = manifest or {} snap_id = hash_snapshot(m) committed_at = datetime.datetime.now(datetime.timezone.utc) commit_id = hash_commit( parent_ids=[parent_id] if parent_id else [], snapshot_id=snap_id, message=message, committed_at_iso=committed_at.isoformat(), author=author, ) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m)) write_commit(root, CommitRecord( commit_id=commit_id, branch=branch, snapshot_id=snap_id, message=message, committed_at=committed_at, parent_commit_id=parent_id, author=author, )) ref_file.parent.mkdir(parents=True, exist_ok=True) ref_file.write_text(commit_id, encoding="utf-8") # Only sync the working tree when this commit is on the current HEAD branch. # Commits on other branches must not disturb the working tree — the dirty- # tree guard would otherwise fire when the command runs against HEAD. if branch == _current_head_branch(root): apply_manifest(root, prev_manifest if prev_manifest is not None else {}, m) return commit_id def _switch_branch(root: pathlib.Path, branch: str) -> None: """Point HEAD at branch and sync the working tree to its tip.""" from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8") ref_file = ref_path(root, branch) if not ref_file.exists(): return commit = read_commit(root, ref_file.read_text().strip()) if commit is None: return snap = read_snapshot(root, commit.snapshot_id) target: Manifest = dict(snap.manifest) if snap else {} # Determine current on-disk state by reading the previous branch's snapshot. apply_manifest(root, {}, target) def _write_object(root: pathlib.Path, content: bytes) -> str: from muse.core.object_store import write_object oid = blob_id(content) write_object(root, oid, content) return oid def _head_commit(root: pathlib.Path, branch: str = "main") -> "CommitRecord | None": from muse.core.commits import ( CommitRecord, read_commit, ) commit_id = ref_path(root, branch).read_text().strip() return read_commit(root, commit_id) # --------------------------------------------------------------------------- # A1 — muse merge: merge commit is authored by the current user # --------------------------------------------------------------------------- def test_a1_merge_commit_has_author(tmp_path: pathlib.Path) -> None: """A1: after ``muse merge``, the new merge commit has author == user.handle. RED: merge.py omits author from hash_commit() and CommitRecord. GREEN: merge reads user.handle and passes it to both. """ root, repo_id = _init_repo(tmp_path) _set_user_handle(root, _HANDLE) # Two branches diverge from a common base — forces a real merge commit. obj_main = _write_object(root, b"main-only-file") obj_feat = _write_object(root, b"feature-only-file") base_id = _make_commit(root, repo_id, "main") # feature branches from base, adds its own file (HEAD stays on main — no workdir change) ref_path(root, "feature").parent.mkdir(parents=True, exist_ok=True) ref_path(root, "feature").write_text(base_id) _make_commit(root, repo_id, "feature", manifest={"feat.py": obj_feat}) # main also advances (diverges); workdir transitions from {} to {"main.py": obj_main} _make_commit(root, repo_id, "main", manifest={"main.py": obj_main}, prev_manifest={}) result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False) assert result.exit_code == 0, f"stdout={result.output!r} stderr={result.stderr!r}" commit = _head_commit(root, "main") assert commit is not None assert commit.parent2_commit_id is not None, "expected a merge commit with two parents" assert commit.author == _HANDLE, ( f"merge commit has author={commit.author!r}, expected {_HANDLE!r}.\n" "Fix: merge.py must read user.handle and pass it to compute_commit_id + CommitRecord." ) # --------------------------------------------------------------------------- # A2 — muse revert: revert commit is authored by the current user # --------------------------------------------------------------------------- def test_a2_revert_commit_has_author(tmp_path: pathlib.Path) -> None: """A2: after ``muse revert``, the new revert commit has author == user.handle. RED: revert.py omits author from hash_commit() and CommitRecord. GREEN: revert reads user.handle and passes it to both. """ root, repo_id = _init_repo(tmp_path) _set_user_handle(root, _HANDLE) _make_commit(root, repo_id, "main", message="initial") target_id = _make_commit(root, repo_id, "main", message="bad change") result = runner.invoke(cli, ["revert", target_id], env=_env(root), catch_exceptions=False) assert result.exit_code == 0, f"stdout={result.output!r} stderr={result.stderr!r}" commit = _head_commit(root, "main") assert commit is not None assert "revert" in commit.message.lower(), "expected a revert commit message" assert commit.author == _HANDLE, ( f"revert commit has author={commit.author!r}, expected {_HANDLE!r}.\n" "Fix: revert.py must read user.handle and pass it to compute_commit_id + CommitRecord." ) # --------------------------------------------------------------------------- # A3 — muse cherry-pick: preserves the original commit's author # --------------------------------------------------------------------------- def test_a3_cherry_pick_preserves_original_author(tmp_path: pathlib.Path) -> None: """A3: cherry-pick preserves the author of the source commit. The cherry-picker replays someone else's work — the author should reflect who wrote the original code, not who ran cherry-pick. RED: cherry_pick.py omits author entirely. GREEN: cherry_pick.py passes target.author to compute_commit_id + CommitRecord. """ root, repo_id = _init_repo(tmp_path) _set_user_handle(root, _HANDLE) original_author = "alice" obj_base = _write_object(root, b"base") obj_new = _write_object(root, b"new") # HEAD is on main; workdir transitions from {} to {"base.py": obj_base} _make_commit(root, repo_id, "main", manifest={"base.py": obj_base}, prev_manifest={}) # create the commit to cherry-pick on a feature branch (HEAD stays on main) ref_path(root, "feature").parent.mkdir(parents=True, exist_ok=True) ref_path(root, "feature").write_text(ref_path(root, "main").read_text()) target_id = _make_commit( root, repo_id, "feature", manifest={"base.py": obj_base, "new.py": obj_new}, message="feat: add new.py", author=original_author, ) result = runner.invoke(cli, ["cherry-pick", target_id], env=_env(root), catch_exceptions=False) assert result.exit_code == 0, f"stdout={result.output!r} stderr={result.stderr!r}" commit = _head_commit(root, "main") assert commit is not None assert commit.author == original_author, ( f"cherry-pick commit has author={commit.author!r}, expected {original_author!r}.\n" "Fix: cherry_pick.py must pass target.author to compute_commit_id + CommitRecord." ) # --------------------------------------------------------------------------- # A4 — muse rebase --squash: squash commit preserves first original author # --------------------------------------------------------------------------- def test_a4_squash_rebase_preserves_first_commit_author(tmp_path: pathlib.Path) -> None: """A4: ``muse rebase --squash`` preserves the author of the first squashed commit. This mirrors git's squash behavior: the resulting commit carries the authorship of the first commit in the squash range. RED: rebase.py squash path omits author. GREEN: rebase.py squash passes commits_to_replay[0].author to compute_commit_id + CommitRecord. """ root, repo_id = _init_repo(tmp_path) _set_user_handle(root, _HANDLE) original_author = "bob" obj_base = _write_object(root, b"base") obj_v1 = _write_object(root, b"v1") obj_v2 = _write_object(root, b"v2") # HEAD is on main; workdir transitions from {} to {"base.py": obj_base} base_id = _make_commit(root, repo_id, "main", manifest={"base.py": obj_base}, prev_manifest={}) # feature branch with two commits to squash (HEAD stays on main — no workdir change) ref_path(root, "feature").parent.mkdir(parents=True, exist_ok=True) ref_path(root, "feature").write_text(base_id) _make_commit( root, repo_id, "feature", manifest={"base.py": obj_base, "feat.py": obj_v1}, message="feat: step 1", author=original_author, ) _make_commit( root, repo_id, "feature", manifest={"base.py": obj_base, "feat.py": obj_v2}, message="feat: step 2", author=original_author, ) # switch to feature — HEAD moves and workdir syncs to feature tip _switch_branch(root, "feature") result = runner.invoke( cli, ["rebase", "--squash", "main"], env=_env(root), catch_exceptions=False ) assert result.exit_code == 0, result.output commit = _head_commit(root, "feature") assert commit is not None assert commit.author == original_author, ( f"squash commit has author={commit.author!r}, expected {original_author!r}.\n" "Fix: rebase.py squash must pass commits_to_replay[0].author to " "compute_commit_id + CommitRecord." )