core_blame.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
| 1 | """``muse blame`` (core VCS) — line-level attribution for any text file. |
| 2 | |
| 3 | Shows which commit last modified each line of a tracked text file — the |
| 4 | universal, domain-agnostic counterpart to ``muse code blame`` (symbol-level) |
| 5 | and ``muse midi note-blame`` (bar-level). |
| 6 | |
| 7 | Usage:: |
| 8 | |
| 9 | muse blame README.md |
| 10 | muse blame --ref v1.0.0 docs/design.md |
| 11 | muse blame --porcelain state/config.toml # machine-readable output |
| 12 | |
| 13 | Output format (default):: |
| 14 | |
| 15 | <sha12> (<author> <date>) 1 line content here |
| 16 | |
| 17 | The ``--porcelain`` flag emits one JSON object per line for pipeline use. |
| 18 | |
| 19 | Note: this command lives as ``muse blame`` at the Tier 2 porcelain level. |
| 20 | Domain-specific blame (symbols, notes) lives under ``muse code`` and |
| 21 | ``muse midi`` respectively. |
| 22 | """ |
| 23 | |
| 24 | from __future__ import annotations |
| 25 | |
| 26 | import argparse |
| 27 | import json |
| 28 | import logging |
| 29 | import pathlib |
| 30 | import sys |
| 31 | |
| 32 | |
| 33 | from muse.core.blame import blame_file |
| 34 | from muse.core.errors import ExitCode |
| 35 | from muse.core.repo import read_repo_id, require_repo |
| 36 | from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref |
| 37 | from muse.core.validation import clamp_int, sanitize_display |
| 38 | |
| 39 | logger = logging.getLogger(__name__) |
| 40 | |
| 41 | |
| 42 | def _read_branch(root: pathlib.Path) -> str: |
| 43 | return read_current_branch(root) |
| 44 | |
| 45 | |
| 46 | |
| 47 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 48 | """Register the blame subcommand.""" |
| 49 | parser = subparsers.add_parser( |
| 50 | "blame", |
| 51 | help="Show which commit last modified each line of a text file.", |
| 52 | description=__doc__, |
| 53 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 54 | ) |
| 55 | parser.add_argument( |
| 56 | "file", |
| 57 | help="Workspace-relative path to the file to blame.", |
| 58 | ) |
| 59 | parser.add_argument( |
| 60 | "--ref", default=None, |
| 61 | help="Commit ref (SHA, branch, tag) to blame at (default: HEAD).", |
| 62 | ) |
| 63 | parser.add_argument( |
| 64 | "--porcelain", action="store_true", |
| 65 | help="Emit one JSON object per line (machine-readable).", |
| 66 | ) |
| 67 | parser.add_argument( |
| 68 | "--short", type=int, default=12, metavar="N", |
| 69 | help="Number of characters to show for each commit SHA (default: 12).", |
| 70 | ) |
| 71 | parser.set_defaults(func=run) |
| 72 | |
| 73 | |
| 74 | def run(args: argparse.Namespace) -> None: |
| 75 | """Show which commit last modified each line of a text file. |
| 76 | |
| 77 | Walks the commit history backwards and attributes each line to the |
| 78 | oldest commit that introduced or last changed it. |
| 79 | |
| 80 | Examples:: |
| 81 | |
| 82 | muse blame README.md |
| 83 | muse blame --ref v1.0.0 src/main.py |
| 84 | muse blame --porcelain config.toml | jq '.commit_id' |
| 85 | """ |
| 86 | file: str = args.file |
| 87 | ref: str | None = args.ref |
| 88 | porcelain: bool = args.porcelain |
| 89 | short: int = clamp_int(args.short, 1, 1000, 'short') |
| 90 | |
| 91 | root = require_repo() |
| 92 | branch = _read_branch(root) |
| 93 | repo_id = read_repo_id(root) |
| 94 | |
| 95 | if ref is None: |
| 96 | commit_id = get_head_commit_id(root, branch) |
| 97 | if not commit_id: |
| 98 | print("❌ No commits yet on this branch.") |
| 99 | raise SystemExit(ExitCode.USER_ERROR) |
| 100 | else: |
| 101 | commit = resolve_commit_ref(root, repo_id, branch, ref) |
| 102 | if commit is None: |
| 103 | print(f"❌ Ref '{sanitize_display(ref)}' not found.") |
| 104 | raise SystemExit(ExitCode.USER_ERROR) |
| 105 | commit_id = commit.commit_id |
| 106 | |
| 107 | lines = blame_file(root, file, commit_id) |
| 108 | if lines is None: |
| 109 | print(f"❌ File '{sanitize_display(file)}' not found at {commit_id[:short]}.") |
| 110 | raise SystemExit(ExitCode.USER_ERROR) |
| 111 | |
| 112 | if not lines: |
| 113 | print(f"(empty file '{sanitize_display(file)}')") |
| 114 | return |
| 115 | |
| 116 | if porcelain: |
| 117 | for bl in lines: |
| 118 | print(json.dumps({ |
| 119 | "lineno": bl.lineno, |
| 120 | "commit_id": bl.commit_id, |
| 121 | "author": bl.author, |
| 122 | "committed_at": bl.committed_at, |
| 123 | "message": bl.message, |
| 124 | "content": bl.content, |
| 125 | })) |
| 126 | return |
| 127 | |
| 128 | # Human-readable output: align columns. |
| 129 | sha_w = short |
| 130 | author_w = min(20, max((len(bl.author) for bl in lines), default=0)) |
| 131 | date_w = 10 |
| 132 | lineno_w = len(str(len(lines))) |
| 133 | |
| 134 | for bl in lines: |
| 135 | sha = bl.commit_id[:sha_w] |
| 136 | author = bl.author[:author_w].ljust(author_w) |
| 137 | date = bl.committed_at[:date_w] |
| 138 | lineno = str(bl.lineno).rjust(lineno_w) |
| 139 | content = sanitize_display(bl.content) |
| 140 | print(f"{sha} ({sanitize_display(author)} {date}) {lineno} {sanitize_display(content)}") |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
33 days ago