"""Phase 3 TDD tests for ``muse bridge git-export``. Tests are organised into eight tiers: Tier 1 — Shape/Schema flag presence, dry-run output, default values Tier 2 — Round-Trip full export integration tests Tier 3 — Edge Cases bad --git-dir, new branches, spaces in paths, etc. Tier 4 — Stress 500-file snapshot export Tier 5 — Data Integrity SHA-256 correctness, message traceability, bridge state Tier 6 — Performance time gates for delete+replace cycles Tier 7 — Security shell injection, branch name validation, fix-modes safety Tier 8 — Docstrings implementation docstrings present NOTE: git subprocess calls in this file are INTENTIONAL — they create real git repositories used as export targets. The bridge command itself is the Muse CLI. The muse codebase otherwise never uses git. """ from __future__ import annotations import hashlib import json import os import pathlib import subprocess import time from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(*args: str, cwd: pathlib.Path | None = None) -> "CliRunner": """Invoke the muse CLI from *cwd*.""" return runner.invoke(None, list(args), cwd=cwd) def _make_git_repo(path: pathlib.Path) -> pathlib.Path: """Create an empty git repo with an initial commit so HEAD exists.""" path.mkdir(parents=True, exist_ok=True) subprocess.run(["git", "init", str(path)], check=True, capture_output=True) subprocess.run( ["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True, capture_output=True, ) subprocess.run( ["git", "-C", str(path), "config", "user.name", "Test"], check=True, capture_output=True, ) (path / "README.md").write_text("init") subprocess.run( ["git", "-C", str(path), "add", "."], check=True, capture_output=True ) subprocess.run( ["git", "-C", str(path), "commit", "-m", "init"], check=True, capture_output=True, ) return path def _make_muse_repo(path: pathlib.Path, files: Mapping[str, str] | None = None) -> pathlib.Path: """Initialise a Muse repo at *path* and commit *files* (or a default set). Returns the repo root path. """ path.mkdir(parents=True, exist_ok=True) result = _invoke("init", cwd=path) assert result.exit_code == 0, f"muse init failed: {result.stderr}" if files is None: files = {"hello.txt": "hello world\n", "src/main.py": "print('hi')\n"} for rel, content in files.items(): full = path / rel full.parent.mkdir(parents=True, exist_ok=True) full.write_text(content) # Stage and commit add_result = _invoke("code", "add", ".", cwd=path) assert add_result.exit_code == 0, f"muse code add failed: {add_result.stderr}" commit_result = _invoke( "commit", "-m", "feat: initial files", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign", cwd=path, ) assert commit_result.exit_code == 0, f"muse commit failed: {commit_result.stderr}" return path def _git_log_count(git_dir: pathlib.Path, branch: str = "HEAD") -> int: """Return the number of commits on *branch* in the git repo at *git_dir*.""" result = subprocess.run( ["git", "-C", str(git_dir), "rev-list", "--count", branch], capture_output=True, ) if result.returncode != 0: return 0 return int(result.stdout.decode().strip()) def _git_latest_sha(git_dir: pathlib.Path) -> str: """Return the HEAD commit SHA in the git repo at *git_dir*.""" result = subprocess.run( ["git", "-C", str(git_dir), "log", "--format=%H", "-1"], capture_output=True, ) return result.stdout.decode().strip() def _git_latest_message(git_dir: pathlib.Path) -> str: """Return the HEAD commit message in the git repo.""" result = subprocess.run( ["git", "-C", str(git_dir), "log", "--format=%s", "-1"], capture_output=True, ) return result.stdout.decode().strip() def _git_file_list(git_dir: pathlib.Path, branch: str = "HEAD") -> list[str]: """Return sorted list of tracked file paths in *git_dir*.""" result = subprocess.run( ["git", "-C", str(git_dir), "ls-tree", "-r", "--name-only", branch], capture_output=True, ) if result.returncode != 0: return [] return sorted(result.stdout.decode().splitlines()) def _git_file_content(git_dir: pathlib.Path, rel_path: str) -> bytes: """Return the content of *rel_path* in *git_dir*.""" return (git_dir / rel_path).read_bytes() # --------------------------------------------------------------------------- # Tier 1 — Shape / Schema # --------------------------------------------------------------------------- class TestSchemaFlags: """Flag presence, dry-run shape, default values.""" def test_help_has_muse_ref(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "--muse-ref" in r.output def test_help_has_git_dir(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "--git-dir" in r.output def test_help_has_git_branch(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "--git-branch" in r.output def test_help_has_no_push(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "--no-push" in r.output def test_help_has_fix_modes(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "--fix-modes" in r.output def test_help_has_watch(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "--watch" in r.output def test_dry_run_emits_json_with_dry_run_true(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--dry-run", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.output data = json.loads(r.output.strip()) assert data["dry_run"] is True def test_default_commit_message_contains_commit_id_placeholder(self) -> None: r = _invoke("bridge", "git-export", "--help") assert "{commit_id}" in r.output def test_strip_muse_metadata_default_true(self, tmp_path: pathlib.Path) -> None: """By default .muse/ must be absent from the exported git tree.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr files = _git_file_list(git_dir, branch="muse-mirror") assert not any(f.startswith(".muse/") for f in files), ( f"Found .muse/ files in git export: {[f for f in files if f.startswith('.muse/')]}" ) # --------------------------------------------------------------------------- # Tier 2 — Round-Trip / Integration # --------------------------------------------------------------------------- class TestRoundTrip: """Full export integration: Muse HEAD → git commit.""" def test_export_creates_git_commit(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, f"export failed:\nstdout={r.output}\nstderr={r.stderr}" data = json.loads(r.output.strip()) assert data["git_sha"] != "" def test_export_produces_mirror_commit_message(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr msg = _git_latest_message(git_dir) assert msg.startswith("mirror: muse sha256:"), ( f"Expected 'mirror: muse sha256:...' but got: {msg!r}" ) def test_export_twice_no_new_muse_commit_no_new_git_commit( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) # First export r1 = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r1.exit_code == 0, r1.stderr sha1 = _git_latest_sha(git_dir) # Second export — same Muse HEAD, no changes r2 = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r2.exit_code == 0, r2.stderr data2 = json.loads(r2.output.strip()) # No new git commit (git_sha empty means nothing committed) assert data2["git_sha"] == "" or _git_latest_sha(git_dir) == sha1 def test_fix_modes_sets_644_on_files(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--fix-modes", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr import stat for p in git_dir.rglob("*"): if p.is_file() and ".git" not in p.parts: mode = p.stat().st_mode & 0o777 assert mode == 0o644, f"{p} has mode {oct(mode)}, expected 0o644" def test_exclude_pattern_omits_files(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir, files={ "secret.key": "TOP SECRET", "public.txt": "public data", }) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--exclude", "*.key", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr files = _git_file_list(git_dir, branch="muse-mirror") assert "secret.key" not in files assert "public.txt" in files def test_bridge_state_last_export_written(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr from muse.core.bridge.state import read_bridge_state state = read_bridge_state(muse_dir) le = state["last_export"] assert le.get("muse_commit_id", "").startswith("sha256:") assert le.get("git_ref") == "muse-mirror" def test_no_push_does_not_push(self, tmp_path: pathlib.Path) -> None: """With --no-push the 'pushed' field is False in JSON output.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) assert data["pushed"] is False def test_muse_dir_absent_from_git_tree(self, tmp_path: pathlib.Path) -> None: """The .muse/ directory must never appear in the exported git tree.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", cwd=muse_dir, ) files = _git_file_list(git_dir, branch="muse-mirror") # Files starting with ".muse/" (directory) must be absent. # Root-level .museattributes and .museignore are legitimate repo files. assert not any(f.startswith(".muse/") for f in files), ( f"Found .muse/ directory files in git export: " f"{[f for f in files if f.startswith('.muse/')]}" ) def test_git_bridge_toml_absent_from_git_tree(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", cwd=muse_dir, ) files = _git_file_list(git_dir, branch="muse-mirror") assert ".muse/git-bridge.toml" not in files # --------------------------------------------------------------------------- # Tier 3 — Edge Cases # --------------------------------------------------------------------------- class TestEdgeCases: """Edge case and error handling tests.""" def test_git_dir_not_git_repo_exits_user_error( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" not_git = tmp_path / "not_git" not_git.mkdir() _make_muse_repo(muse_dir) from muse.core.errors import ExitCode r = _invoke( "bridge", "git-export", "--git-dir", str(not_git), "--no-push", cwd=muse_dir, ) assert r.exit_code == ExitCode.USER_ERROR def test_git_branch_not_yet_in_git_is_created( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) new_branch = "new-export-branch" r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--git-branch", new_branch, "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr # Branch should now exist result = subprocess.run( ["git", "-C", str(git_dir), "rev-parse", "--verify", new_branch], capture_output=True, ) assert result.returncode == 0, f"Branch {new_branch!r} not created" def test_muse_ref_nonexistent_branch_exits_user_error( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) from muse.core.errors import ExitCode r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--muse-ref", "nonexistent-branch", "--no-push", cwd=muse_dir, ) assert r.exit_code == ExitCode.USER_ERROR def test_allow_empty_creates_commit_with_no_changes( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) # First export — creates a commit r1 = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r1.exit_code == 0, r1.stderr sha1 = _git_latest_sha(git_dir) # Second export with --allow-empty — should create another commit even though # nothing changed r2 = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--allow-empty", "--json", cwd=muse_dir, ) assert r2.exit_code == 0, r2.stderr data2 = json.loads(r2.output.strip()) assert data2["git_sha"] != "", "Expected a new commit with --allow-empty" sha2 = _git_latest_sha(git_dir) assert sha2 != sha1 def test_spaces_in_file_path_exported_correctly( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir, files={ "file with spaces.txt": "content here\n", "normal.txt": "normal\n", }) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr files = _git_file_list(git_dir, branch="muse-mirror") assert "file with spaces.txt" in files def test_git_dir_with_dirty_working_tree_proceeds( self, tmp_path: pathlib.Path ) -> None: """Export should proceed even when the git working tree is dirty.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) # Dirty the git working tree (git_dir / "dirty_file.txt").write_text("uncommitted change") r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, f"Expected success with dirty git tree:\n{r.stderr}" # --------------------------------------------------------------------------- # Tier 4 — Stress # --------------------------------------------------------------------------- class TestStress: """High-volume export tests.""" @pytest.mark.timeout(15) def test_export_500_files_completes_under_15s( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" # Create a muse repo with 500 files files = {f"file_{i:04d}.txt": f"content {i}\n" for i in range(500)} _make_muse_repo(muse_dir, files=files) _make_git_repo(git_dir) start = time.monotonic() r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) elapsed = time.monotonic() - start assert r.exit_code == 0, r.stderr assert elapsed < 15.0, f"Export took {elapsed:.1f}s > 15s" data = json.loads(r.output.strip()) # muse init also creates .museattributes and .museignore → 502 total assert data["files_written"] >= 500 # --------------------------------------------------------------------------- # Tier 5 — Data Integrity # --------------------------------------------------------------------------- class TestDataIntegrity: """SHA-256 correctness, message traceability, bridge state accuracy.""" def test_exported_file_sha256_matches_muse_object_store( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" files = { "alpha.txt": "alpha content\n", "beta.py": "x = 1\n", } _make_muse_repo(muse_dir, files=files) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr # Every exported file content must match what's in the Muse snapshot for rel, content_str in files.items(): exported_bytes = _git_file_content(git_dir, rel) expected_bytes = content_str.encode() assert exported_bytes == expected_bytes, ( f"Content mismatch for {rel}: " f"got {exported_bytes!r}, expected {expected_bytes!r}" ) def test_git_commit_message_contains_muse_commit_id( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) muse_commit_id = data["muse_commit_id"] git_msg = _git_latest_message(git_dir) assert muse_commit_id in git_msg, ( f"Muse commit ID {muse_commit_id!r} not in git message {git_msg!r}" ) def test_bridge_state_git_sha_matches_git_log( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", cwd=muse_dir, ) from muse.core.bridge.state import read_bridge_state state = read_bridge_state(muse_dir) bridge_sha = state["last_export"].get("git_sha", "") actual_sha = _git_latest_sha(git_dir) assert bridge_sha == actual_sha, ( f"Bridge state git_sha {bridge_sha!r} != git HEAD {actual_sha!r}" ) def test_muse_commit_id_in_json_output_matches_bridge_state( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) json_commit_id = data["muse_commit_id"] from muse.core.bridge.state import read_bridge_state state = read_bridge_state(muse_dir) bridge_commit_id = state["last_export"].get("muse_commit_id", "") assert json_commit_id == bridge_commit_id # --------------------------------------------------------------------------- # Tier 6 — Performance # --------------------------------------------------------------------------- class TestPerformance: """Time-gated performance tests.""" @pytest.mark.timeout(5) def test_delete_replace_200_files_under_5s( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" files = {f"perf_{i:04d}.txt": f"line {i}\n" for i in range(200)} _make_muse_repo(muse_dir, files=files) _make_git_repo(git_dir) # First export to populate git with 200 files r1 = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", cwd=muse_dir, ) assert r1.exit_code == 0, r1.stderr # Second export should delete+replace 200 files quickly start = time.monotonic() r2 = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--allow-empty", cwd=muse_dir, ) elapsed = time.monotonic() - start assert r2.exit_code == 0, r2.stderr assert elapsed < 5.0, f"Delete+replace took {elapsed:.1f}s > 5s" @pytest.mark.timeout(10) def test_full_export_cycle_200_files_under_10s( self, tmp_path: pathlib.Path ) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" files = {f"cycle_{i:04d}.txt": f"data {i}\n" for i in range(200)} _make_muse_repo(muse_dir, files=files) _make_git_repo(git_dir) start = time.monotonic() r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) elapsed = time.monotonic() - start assert r.exit_code == 0, r.stderr assert elapsed < 10.0, f"Full export took {elapsed:.1f}s > 10s" # --------------------------------------------------------------------------- # Tier 7 — Security # --------------------------------------------------------------------------- class TestSecurity: """Shell injection, branch name safety, fix-modes scope.""" def test_shell_injection_in_commit_message_is_safe( self, tmp_path: pathlib.Path ) -> None: """--commit-message with shell metacharacters must not be shell-expanded.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) evil_msg = "mirror: muse {commit_id}; echo INJECTED" r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--commit-message", evil_msg, "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr # The git commit message should contain the literal semicolon — not execute it full_msg = subprocess.run( ["git", "-C", str(git_dir), "log", "--format=%B", "-1"], capture_output=True, ).stdout.decode() assert "INJECTED" not in full_msg or ";" in full_msg # semicolon is in the message verbatim def test_git_branch_with_semicolon_is_rejected( self, tmp_path: pathlib.Path ) -> None: """--git-branch with a semicolon should exit with USER_ERROR.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) from muse.core.errors import ExitCode r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--git-branch", "branch;evil", "--no-push", cwd=muse_dir, ) assert r.exit_code == ExitCode.USER_ERROR def test_fix_modes_does_not_chmod_git_directory( self, tmp_path: pathlib.Path ) -> None: """--fix-modes must never touch .git/ internals.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir) _make_git_repo(git_dir) # Record .git/ modes before export git_config = git_dir / ".git" / "config" mode_before = git_config.stat().st_mode r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--fix-modes", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr mode_after = git_config.stat().st_mode assert mode_before == mode_after, ( f".git/config mode changed from {oct(mode_before)} to {oct(mode_after)}" ) # --------------------------------------------------------------------------- # Tier 7b — Shebang-based executable bit (issue #38) # --------------------------------------------------------------------------- class TestHasShebang: """Unit tests for GitExporter._has_shebang.""" def test_bash_shebang(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "run.sh" f.write_bytes(b"#!/usr/bin/env bash\necho hi\n") assert GitExporter._has_shebang(f) is True def test_node_shebang(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "run.mjs" f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n") assert GitExporter._has_shebang(f) is True def test_python_shebang_no_env(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "run.py" f.write_bytes(b"#!/usr/bin/python3\nprint('hi')\n") assert GitExporter._has_shebang(f) is True def test_plain_text_no_shebang(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "README.md" f.write_bytes(b"# title\nhello\n") assert GitExporter._has_shebang(f) is False def test_empty_file_no_shebang(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "empty" f.write_bytes(b"") assert GitExporter._has_shebang(f) is False def test_one_byte_file_no_shebang(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "x" f.write_bytes(b"#") assert GitExporter._has_shebang(f) is False def test_shebang_after_leading_whitespace_is_not_shebang(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter f = tmp_path / "x.sh" f.write_bytes(b" #!/usr/bin/env bash\n") assert GitExporter._has_shebang(f) is False def test_nonexistent_file_returns_false(self, tmp_path: pathlib.Path) -> None: from muse.core.bridge.exporter import GitExporter assert GitExporter._has_shebang(tmp_path / "nonexistent") is False class TestFixFileModesShebang: """fix_file_modes applies 0o755 to shebang files, 0o644 to others.""" def _build_exporter(self, git_dir: pathlib.Path) -> "GitExporter": from unittest.mock import MagicMock from muse.core.bridge.exporter import GitExporter e = MagicMock(spec=GitExporter) e.git_dir = git_dir e.fix_file_modes = GitExporter.fix_file_modes.__get__(e, GitExporter) return e def test_shebang_script_gets_755(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "g" git_dir.mkdir() (git_dir / "scripts").mkdir() script = git_dir / "scripts" / "run.sh" script.write_bytes(b"#!/usr/bin/env bash\necho hi\n") readme = git_dir / "README.md" readme.write_bytes(b"# hello\n") e = self._build_exporter(git_dir) e.fix_file_modes({"scripts/run.sh": "sha256:fake", "README.md": "sha256:fake"}) assert script.stat().st_mode & 0o777 == 0o755 assert readme.stat().st_mode & 0o777 == 0o644 def test_node_script_gets_755(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "g" git_dir.mkdir() f = git_dir / "cli.mjs" f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n") e = self._build_exporter(git_dir) e.fix_file_modes({"cli.mjs": "sha256:fake"}) assert f.stat().st_mode & 0o777 == 0o755 def test_regular_file_gets_644(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "g" git_dir.mkdir() f = git_dir / "main.py" f.write_bytes(b"print('hi')\n") e = self._build_exporter(git_dir) e.fix_file_modes({"main.py": "sha256:fake"}) assert f.stat().st_mode & 0o777 == 0o644 def test_setuid_bit_never_set(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "g" git_dir.mkdir() f = git_dir / "evil.sh" f.write_bytes(b"#!/bin/sh\n") f.chmod(0o4755) # pre-set setuid e = self._build_exporter(git_dir) e.fix_file_modes({"evil.sh": "sha256:fake"}) mode = f.stat().st_mode & 0o7777 assert mode == 0o755, f"setuid not cleared: {oct(mode)}" def test_dotgit_still_never_touched(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "g" (git_dir / ".git").mkdir(parents=True) gitfile = git_dir / ".git" / "HEAD" gitfile.write_bytes(b"ref: refs/heads/main\n") original_mode = gitfile.stat().st_mode e = self._build_exporter(git_dir) e.fix_file_modes({".git/HEAD": "sha256:fake"}) assert gitfile.stat().st_mode == original_mode class TestBridgeExportShebangEndToEnd: """Full bridge git-export round-trip: shebang script lands as git 100755.""" def test_executable_script_exported_as_100755(self, tmp_path: pathlib.Path) -> None: muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir, files={ "scripts/deploy.sh": "#!/usr/bin/env bash\necho deploy\n", "README.md": "# hello\n", }) # Make the script executable in the working tree (for realism; muse # doesn't store mode, so the bridge must derive it from content). (muse_dir / "scripts" / "deploy.sh").chmod(0o755) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--fix-modes", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr result = subprocess.run( ["git", "-C", str(git_dir), "ls-tree", "-r", "muse-mirror"], capture_output=True, text=True, ) assert result.returncode == 0 lines = result.stdout.splitlines() by_path = {parts[3]: parts[0] for l in lines if len(parts := l.split()) == 4} assert by_path.get("scripts/deploy.sh") == "100755", ( f"expected 100755 for deploy.sh, got: {by_path}" ) assert by_path.get("README.md") == "100644" def test_fix_modes_default_is_true(self, tmp_path: pathlib.Path) -> None: """--fix-modes should default to True so exec bits are restored without opt-in.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir, files={ "run.sh": "#!/usr/bin/env bash\necho hi\n", }) _make_git_repo(git_dir) # No --fix-modes flag — should apply by default. r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr # Check on-disk mode of the exported file. exported = git_dir / "run.sh" assert exported.stat().st_mode & 0o777 == 0o755 def test_no_fix_modes_flag_leaves_modes_unchanged(self, tmp_path: pathlib.Path) -> None: """--no-fix-modes disables mode correction entirely.""" muse_dir = tmp_path / "muse" git_dir = tmp_path / "git" _make_muse_repo(muse_dir, files={ "run.sh": "#!/usr/bin/env bash\necho hi\n", }) _make_git_repo(git_dir) r = _invoke( "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--no-fix-modes", "--json", cwd=muse_dir, ) assert r.exit_code == 0, r.stderr # With --no-fix-modes the file should NOT have been chmodded to 755. exported = git_dir / "run.sh" mode = exported.stat().st_mode & 0o777 assert mode != 0o755, f"expected non-755 with --no-fix-modes, got {oct(mode)}" # --------------------------------------------------------------------------- # Tier 8 — Docstrings # --------------------------------------------------------------------------- class TestDocstrings: """Implementation docstrings are present.""" def test_git_exporter_has_class_docstring(self) -> None: from muse.core.bridge.exporter import GitExporter assert GitExporter.__doc__ is not None assert len(GitExporter.__doc__.strip()) > 20 def test_sync_to_git_has_docstring(self) -> None: from muse.core.bridge.exporter import GitExporter assert GitExporter.sync_to_git.__doc__ is not None assert len(GitExporter.sync_to_git.__doc__.strip()) > 10 def test_run_git_export_has_docstring_mentioning_ci(self) -> None: from muse.core.bridge.exporter import run_git_export doc = run_git_export.__doc__ or "" assert len(doc.strip()) > 20 assert "CI" in doc or "MUSE_AGENT" in doc, ( f"run_git_export docstring should mention CI env vars, got: {doc[:200]!r}" )