"""``muse ls-tree`` — list the contents of a tree object at a given ref. Displays the files and synthetic directory entries recorded in a commit's snapshot manifest. Because Muse uses a flat manifest (path → object_id) instead of nested tree objects, directory entries are synthesized on the fly from shared path prefixes. Modes ----- ``muse ls-tree HEAD`` Non-recursive listing of the root. Blobs at the root level appear as ``blob`` entries; any path that has children in a subdirectory is collapsed into a synthetic ``tree`` entry (e.g. ``src/``). ``muse ls-tree -r HEAD`` Recursive listing — all blobs, no synthetic tree entries. ``muse ls-tree HEAD src/`` Scope the listing to the ``src/`` prefix. ``muse ls-tree -d HEAD`` Show only synthetic directory (tree) entries, not blobs. Output formats -------------- Default text:: \\t ``--name-only`` text:: ``--long`` (``-l``) text adds the byte size between ```` and the tab:: \\t JSON (``--json``):: { "status": "ok", "error": "", "treeish": "HEAD", "commit_id": "sha256:", "path_prefix": null, "recursive": false, "entry_count": 3, "entries": [ {"mode": "100644", "type": "blob", "object_id": "sha256:", "size": 12, "path": "file.py"}, {"mode": "040000", "type": "tree", "object_id": "sha256:", "size": null, "path": "src/"} ], "duration_ms": 1.2, "exit_code": 0 } All keys are always present so agents can read them without ``dict.get`` guards. ``"status"`` is always ``"ok"`` on success. ``"path_prefix"`` is ``null`` when no path argument was given; otherwise it echoes the normalised prefix that was applied. ``"recursive"`` reflects whether ``-r`` was passed. ``"entry_count"`` equals ``len(entries)`` — a convenient shortcut that avoids parsing the array just to count it. When ``--name-only`` is combined with ``--json`` the entries contain only ``path`` (no ``object_id``, ``mode``, ``type``, or ``size``). JSON error schema (exit non-zero):: { "status": "error", "error": "", "exit_code": } When ``--json`` is active all errors go to stdout as JSON — no prose on stderr. Agents should parse stdout and check ``status``. Exit codes:: 0 — success 1 — user error: bad ref, path traversal, ANSI in ref 2 — not a Muse repository 3 — I/O error """ import argparse import hashlib import json as _json import logging import pathlib import sys from typing import TypedDict from muse.core.types import long_id from muse.core.paths import ref_path as _ref_path from muse.core.envelope import EnvelopeJson, make_envelope from muse.core.errors import ExitCode from muse.core.object_store import read_object from muse.core.repo import require_repo from muse.core.refs import read_ref from muse.core.refs import ( get_head_commit_id, read_current_branch, ) from muse.core.commits import ( read_commit, resolve_commit_ref, ) from muse.core.snapshots import read_snapshot from muse.core.validation import sanitize_display from muse.core.timing import start_timer logger = logging.getLogger(__name__) _BLOB_MODE = "100644" _TREE_MODE = "040000" # --------------------------------------------------------------------------- # Wire-format TypedDicts # --------------------------------------------------------------------------- type _ManifestMap = dict[str, str] class _LsTreeEntry(TypedDict, total=False): mode: str type: str object_id: str size: int | None path: str class _LsTreeJson(EnvelopeJson): """Stable JSON envelope for ``muse ls-tree --json``. Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. All keys are always present so agents can read them without ``dict.get`` guards. ``status`` is ``"ok"`` on success. """ status: str # "ok" error: str # always "" on success treeish: str commit_id: str path_prefix: str | None # null when no path arg given recursive: bool entry_count: int # len(entries) — convenient shortcut entries: list[_LsTreeEntry] class _LsTreeErrorJson(EnvelopeJson): """Error payload for ``muse ls-tree --json`` on usage or internal errors.""" status: str # "error" error: str # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _emit_error(json_out: bool, msg: str, code: ExitCode, elapsed: float) -> None: """Print an error and raise SystemExit. Never returns. In ``--json`` mode the error goes to stdout as a JSON payload so machine consumers always get parseable output. In text mode it goes to stderr. """ if json_out: print(_json.dumps(_LsTreeErrorJson( **make_envelope(elapsed, exit_code=int(code)), status="error", error=msg, ))) else: print(f"❌ {sanitize_display(msg)}", file=sys.stderr) raise SystemExit(code) def _synthetic_tree_id(manifest: _ManifestMap, prefix: str) -> str: """Return a deterministic ``sha256:``-prefixed ID for the synthetic tree at *prefix*. The ID is the SHA-256 of the sorted ``(path, object_id)`` pairs for all manifest entries that fall under *prefix* (direct and indirect children). Args: manifest: Full flat manifest (path → object_id). prefix: Directory prefix ending with ``/`` (e.g. ``"src/"``). Returns: ``sha256:``-prefixed 64-hex-char canonical object ID. """ h = hashlib.sha256() for path in sorted(manifest): if path.startswith(prefix): line = f"{path}\x00{manifest[path]}\n" h.update(line.encode()) return long_id(h.hexdigest()) def _build_tree_entries( manifest: dict[str, str], path_prefix: str, recursive: bool, ) -> list[dict]: """Build the list of tree entries for a given prefix and recursion mode. Args: manifest: Full flat manifest (path → object_id). path_prefix: Repo-relative POSIX prefix to scope the listing, e.g. ``""`` for root or ``"src/"`` for a subdirectory. recursive: If True, return all blobs (no synthetic tree entries). If False, return immediate children only — blobs for files in this directory level, synthetic tree entries for subdirectories. Returns: Sorted list of entry dicts with keys: ``mode``, ``type``, ``object_id``, ``size`` (None for trees), ``path``. """ if recursive: # Return every blob whose path starts with the prefix. entries = [] for path, oid in sorted(manifest.items()): if path.startswith(path_prefix): entries.append({ "mode": _BLOB_MODE, "type": "blob", "object_id": oid, "size": None, "path": path, }) return entries # Non-recursive: collect immediate children at this directory level. seen_dirs: set[str] = set() entries: list[dict] = [] for path, oid in sorted(manifest.items()): if not path.startswith(path_prefix): continue rel = path[len(path_prefix):] # path relative to the prefix slash = rel.find("/") if slash == -1: # Direct blob child. entries.append({ "mode": _BLOB_MODE, "type": "blob", "object_id": oid, "size": None, "path": path, }) else: # The path passes through a subdirectory — emit a synthetic tree. dir_name = rel[:slash + 1] # e.g. "src/" dir_full = path_prefix + dir_name # e.g. "src/" or "pkg/sub/" if dir_full not in seen_dirs: seen_dirs.add(dir_full) entries.append({ "mode": _TREE_MODE, "type": "tree", "object_id": _synthetic_tree_id(manifest, dir_full), "size": None, "path": dir_full, }) return sorted(entries, key=lambda e: e["path"]) def _resolve_manifest( root: pathlib.Path, treeish: str, json_out: bool, elapsed: float, ) -> tuple[str, dict[str, str]]: """Resolve *treeish* to a ``(commit_id, manifest)`` pair. Resolution order: 1. ``"HEAD"`` — current branch tip. 2. Branch name — ``.muse/refs/heads/``. 3. Full or abbreviated commit ID — prefix scan of commits dir. Args: root: Absolute repo root. treeish: Branch name, commit ID, or ``"HEAD"``. json_out: When True, errors go to stdout as JSON. Returns: ``(commit_id, manifest)`` tuple. Raises: SystemExit(USER_ERROR): ref not found or repo is empty. """ try: branch = read_current_branch(root) commit = None if treeish.upper() == "HEAD": commit_id = get_head_commit_id(root, branch) if not commit_id: _emit_error(json_out, "Repository has no commits yet.", ExitCode.USER_ERROR, elapsed) commit = read_commit(root, commit_id) else: # Try as a branch name first (direct ref file lookup). branch_ref = _ref_path(root, treeish) commit_id = read_ref(branch_ref) if commit_id is not None: commit = read_commit(root, commit_id) else: # Fall back to commit-ID prefix scan. commit = resolve_commit_ref(root, branch, treeish) if commit is None: _emit_error( json_out, f"'{sanitize_display(treeish)}' is not a known branch or commit ID.", ExitCode.USER_ERROR, elapsed, ) commit_id = commit.commit_id snap = read_snapshot(root, commit.snapshot_id) manifest = dict(snap.manifest) if snap else {} return commit_id, manifest except SystemExit: raise except Exception as exc: _emit_error( json_out, f"Failed to resolve '{sanitize_display(treeish)}': {exc}", ExitCode.USER_ERROR, elapsed, ) def _validate_path_prefix(root: pathlib.Path, raw: str, json_out: bool, elapsed: float) -> str: """Validate and normalise a user-supplied path prefix. Rejects path-traversal attempts (``..`` components, absolute paths that escape the repo root). Args: root: Absolute repo root. raw: Raw path string as given by the user. json_out: When True, errors go to stdout as JSON. Returns: Normalised repo-relative POSIX path with trailing ``/`` if it looks like a directory prefix, or as-is for explicit file paths. Raises: SystemExit(USER_ERROR): path traversal detected. """ # Reject paths that try to escape the repo. try: candidate = (root / raw).resolve() candidate.relative_to(root.resolve()) except ValueError: _emit_error( json_out, f"Path '{sanitize_display(raw)}' is outside the repository root.", ExitCode.USER_ERROR, elapsed, ) # Build the normalised relative POSIX path. try: rel = candidate.relative_to(root.resolve()).as_posix() except ValueError: _emit_error( json_out, f"Path '{sanitize_display(raw)}' is outside the repository root.", ExitCode.USER_ERROR, elapsed, ) if rel == ".": return "" # Preserve trailing slash for directory-prefix semantics. if raw.endswith("/"): return f"{rel}/" return rel # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def register( subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", ) -> None: """Register the ``muse ls-tree`` subcommand.""" parser = subparsers.add_parser( "ls-tree", help="List the contents of a snapshot at a given ref.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "-r", "--recursive", action="store_true", dest="recursive", help="Recurse into subtrees, listing all blobs.", ) parser.add_argument( "--dirs-only", action="store_true", dest="dirs_only", help="Show only tree (directory) entries, not blobs.", ) parser.add_argument( "-l", "--long", action="store_true", dest="long", help="Include object size in the listing.", ) parser.add_argument( "--name-only", action="store_true", dest="name_only", help="Show only path names, omitting mode/type/object_id.", ) parser.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) parser.add_argument( "treeish", metavar="TREEISH", nargs="?", default="HEAD", help="Branch name or commit ID to inspect (default: HEAD).", ) parser.add_argument( "path", metavar="PATH", nargs="?", default=None, help="Optional path prefix to scope the listing.", ) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- def run(args: argparse.Namespace) -> None: """List snapshot contents for a given ref. Resolves *treeish* (branch name, commit ID, or ``HEAD``) to a snapshot manifest and emits tree entries. Non-recursive mode synthesizes directory entries from shared path prefixes; recursive mode emits raw blobs only. Agent quickstart ---------------- :: muse ls-tree --json muse ls-tree HEAD src/ --json muse ls-tree -r HEAD --json muse ls-tree feat/billing --json JSON fields ----------- status ``"ok"`` on success. treeish The ref that was resolved. commit_id Commit ID of the resolved snapshot. path_prefix Scoping prefix applied, or ``null``. recursive ``true`` when ``-r`` was passed. entry_count Number of entries returned. entries List of entry objects: ``mode``, ``type``, ``object_id``, ``size``, ``path`` (``size`` is ``null`` for tree entries unless ``--long`` was passed). Exit codes ---------- 0 Success. 1 Bad ref, path traversal, ANSI in ref, or empty repository. 2 Not inside a Muse repository. 3 I/O error. """ elapsed = start_timer() treeish: str = args.treeish or "HEAD" raw_path: str | None = args.path recursive: bool = args.recursive dirs_only: bool = args.dirs_only long_fmt: bool = args.long name_only: bool = args.name_only json_out: bool = args.json_out root = require_repo() # ── Validate ref — reject ANSI and other control characters ────────────── if any(ord(c) < 32 for c in treeish): _emit_error( json_out, f"Invalid ref '{sanitize_display(treeish)}': control characters not allowed.", ExitCode.USER_ERROR, elapsed, ) # ── Resolve the ref to a manifest ──────────────────────────────────────── commit_id, manifest = _resolve_manifest(root, treeish, json_out, elapsed) # ── Validate and normalise path prefix ─────────────────────────────────── path_prefix = "" path_prefix_out: str | None = None # what we echo in the envelope if raw_path is not None: path_prefix = _validate_path_prefix(root, raw_path, json_out, elapsed) # Ensure directory prefixes end with / if path_prefix and not path_prefix.endswith("/"): path_prefix += "/" path_prefix_out = path_prefix or None # ── Build entries ───────────────────────────────────────────────────────── entries = _build_tree_entries(manifest, path_prefix, recursive) # Apply --dirs-only filter. if dirs_only: entries = [e for e in entries if e["type"] == "tree"] # ── Populate sizes when --long is requested ─────────────────────────────── if long_fmt: for entry in entries: if entry["type"] == "blob": data = read_object(root, entry["object_id"]) entry["size"] = len(data) if data is not None else None # ── Output ─────────────────────────────────────────────────────────────── if json_out: if name_only: out_entries = [{"path": e["path"]} for e in entries] else: out_entries = [] for e in entries: out_entries.append({ "mode": e["mode"], "type": e["type"], "object_id": e["object_id"], "size": e["size"], "path": e["path"], }) print(_json.dumps(_LsTreeJson( **make_envelope(elapsed), status="ok", error="", treeish=treeish, commit_id=commit_id, path_prefix=path_prefix_out, recursive=recursive, entry_count=len(out_entries), entries=out_entries, ))) else: for e in entries: if name_only: print(e["path"]) elif long_fmt: size_str = str(e["size"]) if e["size"] is not None else "-" print(f"{e['mode']} {e['type']} {e['object_id']} {size_str}\t{e['path']}") else: print(f"{e['mode']} {e['type']} {e['object_id']}\t{e['path']}")