"""TDD tests for two new ``muse branch`` features. Feature 1 — Branch intent + resumable -------------------------------------- ``muse branch [--intent TEXT] [--resumable]`` - Stores ``intent`` and ``resumable`` in ``.muse/config.toml`` under ``[branch.""]`` on create. - Surfaces both fields in ``branch --json`` listing output. - ``muse branch --resumable`` filters the listing to resumable branches only. Feature 2 — created_by from tip commit --------------------------------------- ``branch --json`` surfaces ``created_by`` (the ``agent_id`` from the tip commit's :class:`CommitRecord`) on every listing entry. Falls back to ``""`` when the branch has no commits or the commit has no agent attribution. Test categories --------------- - unit : config.py helpers (write_branch_meta, read_branch_meta) - integration : parser flags, config.toml round-trip, listing JSON schema - e2e : full CLI round-trips via CliRunner - security : intent injection (ANSI, newlines, TOML metacharacters) - data_integrity: resumable flag, intent survive save → list cycle - performance : listing 50 branches with intent under 1 s """ from __future__ import annotations from collections.abc import Mapping import json import os import pathlib import time import tomllib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.refs import get_head_commit_id from muse.core.paths import config_toml_path, heads_dir runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult: return _invoke(repo, ["branch", *extra]) def _commit(repo: pathlib.Path, msg: str = "commit") -> InvokeResult: return _invoke(repo, ["commit", "-m", msg]) def _config(repo: pathlib.Path) -> Mapping[str, object]: p = config_toml_path(repo) if not p.exists(): return {} with p.open("rb") as f: return tomllib.load(f) @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: saved = os.getcwd() try: os.chdir(tmp_path) runner.invoke(None, ["init"]) finally: os.chdir(saved) (tmp_path / "a.py").write_text("x = 1\n") _commit(tmp_path, "initial") return tmp_path @pytest.fixture() def agent_repo(tmp_path: pathlib.Path) -> pathlib.Path: """Repo with an agent-attributed commit on main.""" saved = os.getcwd() try: os.chdir(tmp_path) runner.invoke(None, ["init"]) finally: os.chdir(saved) (tmp_path / "a.py").write_text("x = 1\n") _invoke(tmp_path, [ "commit", "-m", "agent commit", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", ]) return tmp_path # =========================================================================== # Unit: config.py helpers # =========================================================================== class TestWriteBranchMeta: """write_branch_meta persists intent + resumable to config.toml.""" def test_writes_intent_to_config(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta write_branch_meta(repo, "feat/x", intent="refactor auth") data = _config(repo) assert data["branch"]["feat/x"]["intent"] == "refactor auth" def test_writes_resumable_true(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta write_branch_meta(repo, "task/y", resumable=True) data = _config(repo) assert data["branch"]["task/y"]["resumable"] is True def test_writes_resumable_false(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta write_branch_meta(repo, "task/z", resumable=False) data = _config(repo) assert data["branch"]["task/z"]["resumable"] is False def test_writes_both_fields(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta write_branch_meta(repo, "feat/both", intent="doing X", resumable=True) data = _config(repo) sec = data["branch"]["feat/both"] assert sec["intent"] == "doing X" assert sec["resumable"] is True def test_does_not_clobber_upstream_fields(self, repo: pathlib.Path) -> None: """Existing remote/merge keys must survive a write_branch_meta call.""" p = config_toml_path(repo) p.write_text( '[branch."main"]\nremote = "origin"\nmerge = "refs/heads/main"\n' ) from muse.cli.config import write_branch_meta write_branch_meta(repo, "main", intent="track origin") data = _config(repo) sec = data["branch"]["main"] assert sec.get("remote") == "origin" assert sec.get("merge") == "refs/heads/main" assert sec.get("intent") == "track origin" def test_updates_existing_entry(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta write_branch_meta(repo, "feat/up", intent="first intent", resumable=False) write_branch_meta(repo, "feat/up", intent="updated intent", resumable=True) data = _config(repo) sec = data["branch"]["feat/up"] assert sec["intent"] == "updated intent" assert sec["resumable"] is True def test_multiple_branches_independent(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta write_branch_meta(repo, "feat/a", intent="alpha") write_branch_meta(repo, "feat/b", intent="beta", resumable=True) data = _config(repo) assert data["branch"]["feat/a"]["intent"] == "alpha" assert "resumable" not in data["branch"]["feat/a"] assert data["branch"]["feat/b"]["intent"] == "beta" assert data["branch"]["feat/b"]["resumable"] is True def test_creates_config_file_if_absent(self, repo: pathlib.Path) -> None: p = config_toml_path(repo) p.unlink(missing_ok=True) from muse.cli.config import write_branch_meta write_branch_meta(repo, "new-branch", intent="fresh") assert p.exists() data = _config(repo) assert data["branch"]["new-branch"]["intent"] == "fresh" class TestReadBranchMeta: """read_branch_meta returns the stored dict (or empty) for a branch.""" def test_returns_intent_and_resumable(self, repo: pathlib.Path) -> None: from muse.cli.config import write_branch_meta, read_branch_meta write_branch_meta(repo, "feat/r", intent="do X", resumable=True) meta = read_branch_meta(repo, "feat/r") assert meta.get("intent") == "do X" assert meta.get("resumable") is True def test_returns_empty_for_unknown_branch(self, repo: pathlib.Path) -> None: from muse.cli.config import read_branch_meta assert read_branch_meta(repo, "nonexistent") == {} def test_returns_empty_when_no_config(self, repo: pathlib.Path) -> None: from muse.cli.config import read_branch_meta (config_toml_path(repo)).unlink(missing_ok=True) assert read_branch_meta(repo, "main") == {} # =========================================================================== # Unit: parser flags # =========================================================================== class TestParserFlags: def _parse(self, *args: str) -> "argparse.Namespace": import argparse from muse.cli.commands.branch import register p = argparse.ArgumentParser() sub = p.add_subparsers() register(sub) return p.parse_args(["branch", *args]) def test_intent_flag(self) -> None: ns = self._parse("new-branch", "--intent", "refactor the thing") assert ns.intent == "refactor the thing" def test_intent_default_none(self) -> None: ns = self._parse("new-branch") assert ns.intent is None def test_resumable_flag(self) -> None: ns = self._parse("new-branch", "--resumable") assert ns.resumable is True def test_resumable_default_false(self) -> None: ns = self._parse("new-branch") assert ns.resumable is False def test_resumable_filter_flag(self) -> None: ns = self._parse("--resumable") assert ns.resumable is True # =========================================================================== # Integration: --intent / --resumable on create # =========================================================================== class TestCreateWithIntent: def test_create_with_intent_exits_0(self, repo: pathlib.Path) -> None: result = _branch(repo, "feat/x", "--intent", "do the thing") assert result.exit_code == 0 def test_create_stores_intent_in_config(self, repo: pathlib.Path) -> None: _branch(repo, "feat/config-test", "--intent", "store me") data = _config(repo) assert data["branch"]["feat/config-test"]["intent"] == "store me" def test_create_stores_resumable_in_config(self, repo: pathlib.Path) -> None: _branch(repo, "feat/res", "--resumable") data = _config(repo) assert data["branch"]["feat/res"]["resumable"] is True def test_create_without_intent_no_config_entry(self, repo: pathlib.Path) -> None: _branch(repo, "feat/plain") data = _config(repo) branch_sec = data.get("branch", {}) assert "feat/plain" not in branch_sec def test_create_json_includes_intent(self, repo: pathlib.Path) -> None: result = _branch(repo, "feat/j", "--intent", "json intent", "--json") assert result.exit_code == 0 data = json.loads(result.output) assert data.get("intent") == "json intent" def test_create_json_includes_resumable(self, repo: pathlib.Path) -> None: result = _branch(repo, "feat/jr", "--resumable", "--json") data = json.loads(result.output) assert data.get("resumable") is True def test_create_json_resumable_false_when_not_set(self, repo: pathlib.Path) -> None: result = _branch(repo, "feat/nores", "--json") data = json.loads(result.output) assert data.get("resumable") is False # =========================================================================== # Integration: listing JSON includes intent, resumable, created_by # =========================================================================== class TestListJsonNewFields: def test_list_json_has_intent_field(self, repo: pathlib.Path) -> None: _branch(repo, "feat/listed", "--intent", "listed intent") result = _branch(repo, "--json") data = json.loads(result.output) entry = next(b for b in data if b["name"] == "feat/listed") assert "intent" in entry assert entry["intent"] == "listed intent" def test_list_json_intent_null_for_plain_branch(self, repo: pathlib.Path) -> None: _branch(repo, "feat/no-intent") result = _branch(repo, "--json") data = json.loads(result.output) entry = next(b for b in data if b["name"] == "feat/no-intent") assert entry.get("intent") is None def test_list_json_has_resumable_field(self, repo: pathlib.Path) -> None: _branch(repo, "feat/reslist", "--resumable") result = _branch(repo, "--json") data = json.loads(result.output) entry = next(b for b in data if b["name"] == "feat/reslist") assert "resumable" in entry assert entry["resumable"] is True def test_list_json_resumable_false_for_plain_branch(self, repo: pathlib.Path) -> None: result = _branch(repo, "--json") data = json.loads(result.output) main = next(b for b in data if b["name"] == "main") assert main.get("resumable") is False def test_list_json_has_created_by_field(self, repo: pathlib.Path) -> None: result = _branch(repo, "--json") data = json.loads(result.output) assert "created_by" in data[0] def test_list_json_created_by_from_agent_commit( self, agent_repo: pathlib.Path ) -> None: result = _branch(agent_repo, "--json") data = json.loads(result.output) main = next(b for b in data if b["name"] == "main") assert main["created_by"] == "claude-code" def test_list_json_created_by_empty_for_human_commit( self, repo: pathlib.Path ) -> None: result = _branch(repo, "--json") data = json.loads(result.output) main = next(b for b in data if b["name"] == "main") # Human commit has no agent_id — empty string or null assert main["created_by"] in ("", None) def test_list_json_created_by_empty_for_empty_branch( self, repo: pathlib.Path ) -> None: (heads_dir(repo) / "empty").write_text("") result = _branch(repo, "--json") data = json.loads(result.output) entry = next(b for b in data if b["name"] == "empty") assert entry["created_by"] in ("", None) def test_schema_complete(self, repo: pathlib.Path) -> None: """All new fields must appear in the listing schema.""" result = _branch(repo, "--json") data = json.loads(result.output) required = {"name", "current", "commit_id", "committed_at", "last_message", "upstream", "intent", "resumable", "created_by"} missing = required - set(data[0].keys()) assert not missing, f"branch --json missing fields: {missing}" # =========================================================================== # E2E: --resumable listing filter # =========================================================================== class TestResumableFilter: def test_resumable_filter_shows_only_resumable(self, repo: pathlib.Path) -> None: _branch(repo, "task/resumable-1", "--resumable") _branch(repo, "task/resumable-2", "--resumable") _branch(repo, "task/not-resumable") result = _branch(repo, "--resumable", "--json") assert result.exit_code == 0 data = json.loads(result.output) names = [b["name"] for b in data] assert "task/resumable-1" in names assert "task/resumable-2" in names assert "task/not-resumable" not in names assert "main" not in names def test_resumable_filter_empty_when_none(self, repo: pathlib.Path) -> None: result = _branch(repo, "--resumable", "--json") assert result.exit_code == 0 data = json.loads(result.output) assert data == [] def test_resumable_filter_text_output(self, repo: pathlib.Path) -> None: _branch(repo, "task/res", "--resumable") result = _branch(repo, "--resumable") assert result.exit_code == 0 assert "task/res" in result.output def test_resumable_filter_all_resumable_returned(self, repo: pathlib.Path) -> None: for i in range(5): _branch(repo, f"task/r-{i}", "--resumable") result = _branch(repo, "--resumable", "--json") data = json.loads(result.output) assert len(data) == 5 def test_resumable_combined_with_merged_filter(self, repo: pathlib.Path) -> None: """--resumable and --merged can be combined.""" _branch(repo, "task/merged-resumable", "--resumable") result = _branch(repo, "--resumable", "--merged", "--json") assert result.exit_code == 0 data = json.loads(result.output) names = [b["name"] for b in data] # task/merged-resumable shares HEAD with main, so it's merged assert "task/merged-resumable" in names # =========================================================================== # E2E: full round-trips # =========================================================================== class TestE2eRoundTrips: def test_intent_survives_list_cycle(self, repo: pathlib.Path) -> None: _branch(repo, "feat/rt", "--intent", "round-trip test", "--resumable") result = _branch(repo, "--json") data = json.loads(result.output) entry = next(b for b in data if b["name"] == "feat/rt") assert entry["intent"] == "round-trip test" assert entry["resumable"] is True def test_created_by_survives_new_branch_on_agent_repo( self, agent_repo: pathlib.Path ) -> None: _branch(agent_repo, "child-branch") result = _branch(agent_repo, "--json") data = json.loads(result.output) # child-branch points at same commit as main child = next(b for b in data if b["name"] == "child-branch") assert child["created_by"] == "claude-code" def test_create_intent_resumable_json_schema(self, repo: pathlib.Path) -> None: result = _branch(repo, "feat/full", "--intent", "full schema", "--resumable", "--json") data = json.loads(result.output) assert data["action"] == "created" assert data["intent"] == "full schema" assert data["resumable"] is True assert "branch" in data assert "commit_id" in data def test_resumable_filter_with_r_flag(self, repo: pathlib.Path) -> None: """--resumable must not conflict with -r (remote-tracking) flag.""" _branch(repo, "task/local-res", "--resumable") # -r with no remotes returns empty; should not crash result = _branch(repo, "-r", "--resumable", "--json") assert result.exit_code == 0 assert json.loads(result.output) == [] # =========================================================================== # Security: intent injection # =========================================================================== class TestIntentSecurity: def _has_ansi(self, s: str) -> bool: return "\x1b[" in s def test_ansi_in_intent_stripped_from_output(self, repo: pathlib.Path) -> None: _branch(repo, "sec/ansi", "--intent", "\x1b[31mmalicious\x1b[0m") result = _branch(repo, "--json") data = json.loads(result.output) entry = next(b for b in data if b["name"] == "sec/ansi") assert not self._has_ansi(str(entry.get("intent", ""))) def test_newline_in_intent_escaped_in_toml(self, repo: pathlib.Path) -> None: """Intent with newline must not break TOML file structure.""" _branch(repo, "sec/nl", "--intent", "line1\nline2") # Config file must still be parseable data = _config(repo) assert isinstance(data, dict) def test_toml_metachar_in_intent_safe(self, repo: pathlib.Path) -> None: """TOML-special chars in intent must not allow section injection.""" _branch(repo, "sec/toml", '--intent', '[malicious]\nkey = "injected"') data = _config(repo) # No top-level 'malicious' section should have been injected assert "malicious" not in data def test_intent_truncated_to_reasonable_length(self, repo: pathlib.Path) -> None: """Very long intent must not crash or produce a corrupt config.""" long_intent = "x" * 10_000 result = _branch(repo, "sec/long", "--intent", long_intent) assert result.exit_code == 0 data = _config(repo) stored = data.get("branch", {}).get("sec/long", {}).get("intent", "") assert isinstance(stored, str) # =========================================================================== # Data integrity # =========================================================================== class TestDataIntegrity: def test_intent_not_lost_on_second_branch_create(self, repo: pathlib.Path) -> None: """Creating a second branch must not overwrite the first's intent.""" _branch(repo, "feat/first", "--intent", "first intent") _branch(repo, "feat/second", "--intent", "second intent") data = _config(repo) assert data["branch"]["feat/first"]["intent"] == "first intent" assert data["branch"]["feat/second"]["intent"] == "second intent" def test_resumable_preserved_across_other_branch_operations( self, repo: pathlib.Path ) -> None: _branch(repo, "task/keep", "--resumable") _branch(repo, "task/other", "--intent", "unrelated") data = _config(repo) assert data["branch"]["task/keep"]["resumable"] is True def test_config_toml_valid_toml_after_write(self, repo: pathlib.Path) -> None: _branch(repo, "feat/valid", "--intent", 'quotes "and" stuff', "--resumable") # tomllib.load must succeed p = config_toml_path(repo) with p.open("rb") as f: parsed = tomllib.load(f) assert isinstance(parsed, dict) # =========================================================================== # Performance # =========================================================================== class TestPerformance: def test_list_50_branches_with_intent_under_1s(self, repo: pathlib.Path) -> None: for i in range(50): _branch(repo, f"perf/task-{i:03d}", "--intent", f"task {i}", "--resumable") start = time.monotonic() result = _branch(repo, "--json") elapsed = time.monotonic() - start assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 51 # main + 50 assert elapsed < 1.0, f"listing 51 branches with intent took {elapsed:.2f}s" def test_resumable_filter_50_branches_under_500ms( self, repo: pathlib.Path ) -> None: for i in range(50): _branch(repo, f"filter/task-{i:03d}", "--resumable") start = time.monotonic() result = _branch(repo, "--resumable", "--json") elapsed = time.monotonic() - start assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 50 assert elapsed < 0.5, f"--resumable filter on 50 branches took {elapsed:.2f}s" # --------------------------------------------------------------------------- # Metadata update on existing branch # --------------------------------------------------------------------------- class TestBranchMetaUpdate: """--intent / --resumable on an already-existing branch update metadata.""" def test_set_intent_on_existing_branch(self, repo: pathlib.Path) -> None: _branch(repo, "existing") result = _branch(repo, "existing", "--intent", "added later") assert result.exit_code == 0 def test_update_action_in_json(self, repo: pathlib.Path) -> None: _branch(repo, "upd") result = _branch(repo, "upd", "--intent", "my intent", "--json") assert result.exit_code == 0 data = json.loads(result.output) assert data["action"] == "updated" assert data["branch"] == "upd" assert data["intent"] == "my intent" def test_intent_visible_in_listing_after_update(self, repo: pathlib.Path) -> None: _branch(repo, "later") _branch(repo, "later", "--intent", "set after creation") listing = json.loads(_branch(repo, "--json").output) entry = next(e for e in listing if e["name"] == "later") assert entry["intent"] == "set after creation" def test_set_resumable_on_existing_branch(self, repo: pathlib.Path) -> None: _branch(repo, "checkpoint") result = _branch(repo, "checkpoint", "--resumable", "--json") assert result.exit_code == 0 data = json.loads(result.output) assert data["resumable"] is True def test_resumable_visible_in_listing_after_update(self, repo: pathlib.Path) -> None: _branch(repo, "chkpt2") _branch(repo, "chkpt2", "--resumable") listing = json.loads(_branch(repo, "--json").output) entry = next(e for e in listing if e["name"] == "chkpt2") assert entry["resumable"] is True def test_update_does_not_overwrite_unspecified_fields( self, repo: pathlib.Path ) -> None: """Setting resumable later must not wipe a previously stored intent.""" _branch(repo, "preserve", "--intent", "keep me") _branch(repo, "preserve", "--resumable") listing = json.loads(_branch(repo, "--json").output) entry = next(e for e in listing if e["name"] == "preserve") assert entry["intent"] == "keep me" assert entry["resumable"] is True def test_update_with_start_point_still_errors(self, repo: pathlib.Path) -> None: """Passing a start_point to an existing branch is still an error.""" _branch(repo, "existing2") result = _branch(repo, "existing2", "main", "--intent", "x") assert result.exit_code != 0 def test_no_meta_flags_still_errors_on_existing(self, repo: pathlib.Path) -> None: """Plain `muse branch ` (no --intent/--resumable) still errors.""" _branch(repo, "plain") result = _branch(repo, "plain") assert result.exit_code != 0