"""``muse blame`` (core VCS) — line-level attribution for any text file. Shows which commit last modified each line of a tracked text file — the universal, domain-agnostic counterpart to ``muse code blame`` (symbol-level) and ``muse midi note-blame`` (bar-level). Usage:: muse blame README.md muse blame --ref v1.0.0 docs/design.md muse blame --porcelain state/config.toml # machine-readable output Output format (default):: ( ) 1 line content here The ``--porcelain`` flag emits one JSON object per line for pipeline use. Note: this command lives as ``muse blame`` at the Tier 2 porcelain level. Domain-specific blame (symbols, notes) lives under ``muse code`` and ``muse midi`` respectively. """ from __future__ import annotations import argparse import json import logging import pathlib import sys from muse.core.blame import blame_file from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref from muse.core.validation import clamp_int, sanitize_display logger = logging.getLogger(__name__) def _read_branch(root: pathlib.Path) -> str: return read_current_branch(root) def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the blame subcommand.""" parser = subparsers.add_parser( "blame", help="Show which commit last modified each line of a text file.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "file", help="Workspace-relative path to the file to blame.", ) parser.add_argument( "--ref", default=None, help="Commit ref (SHA, branch, tag) to blame at (default: HEAD).", ) parser.add_argument( "--porcelain", action="store_true", help="Emit one JSON object per line (machine-readable).", ) parser.add_argument( "--short", type=int, default=12, metavar="N", help="Number of characters to show for each commit SHA (default: 12).", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Show which commit last modified each line of a text file. Walks the commit history backwards and attributes each line to the oldest commit that introduced or last changed it. Examples:: muse blame README.md muse blame --ref v1.0.0 src/main.py muse blame --porcelain config.toml | jq '.commit_id' """ file: str = args.file ref: str | None = args.ref porcelain: bool = args.porcelain short: int = clamp_int(args.short, 1, 1000, 'short') root = require_repo() branch = _read_branch(root) repo_id = read_repo_id(root) if ref is None: commit_id = get_head_commit_id(root, branch) if not commit_id: print("❌ No commits yet on this branch.") raise SystemExit(ExitCode.USER_ERROR) else: commit = resolve_commit_ref(root, repo_id, branch, ref) if commit is None: print(f"❌ Ref '{sanitize_display(ref)}' not found.") raise SystemExit(ExitCode.USER_ERROR) commit_id = commit.commit_id lines = blame_file(root, file, commit_id) if lines is None: print(f"❌ File '{sanitize_display(file)}' not found at {commit_id[:short]}.") raise SystemExit(ExitCode.USER_ERROR) if not lines: print(f"(empty file '{sanitize_display(file)}')") return if porcelain: for bl in lines: print(json.dumps({ "lineno": bl.lineno, "commit_id": bl.commit_id, "author": bl.author, "committed_at": bl.committed_at, "message": bl.message, "content": bl.content, })) return # Human-readable output: align columns. sha_w = short author_w = min(20, max((len(bl.author) for bl in lines), default=0)) date_w = 10 lineno_w = len(str(len(lines))) for bl in lines: sha = bl.commit_id[:sha_w] author = bl.author[:author_w].ljust(author_w) date = bl.committed_at[:date_w] lineno = str(bl.lineno).rjust(lineno_w) content = sanitize_display(bl.content) print(f"{sha} ({sanitize_display(author)} {date}) {lineno} {sanitize_display(content)}")