hash_object.py
python
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50
docs: add symlog (#53) and reflog (#54) follow-up issue files
Sonnet 4.6
20 days ago
| 1 | """muse hash-object — compute the SHA-256 object ID of a file. |
| 2 | |
| 3 | Computes the content-addressed object ID (SHA-256 hex digest) of a file or |
| 4 | stdin stream. With ``--write`` the object is also stored in ``.muse/objects/`` |
| 5 | so it can be referenced by future snapshots and commits. |
| 6 | |
| 7 | Output (JSON, default):: |
| 8 | |
| 9 | {"object_id": "<sha256>", "stored": false} |
| 10 | |
| 11 | Output (--format text):: |
| 12 | |
| 13 | <sha256> |
| 14 | |
| 15 | Output contract |
| 16 | --------------- |
| 17 | |
| 18 | - Exit 0: hash computed successfully. |
| 19 | - Exit 1: file not found, path is a directory, or unknown --format value. |
| 20 | - Exit 3: I/O error writing to the store, or integrity check failed. |
| 21 | |
| 22 | Agent use |
| 23 | --------- |
| 24 | |
| 25 | Read from stdin to avoid temp-file overhead:: |
| 26 | |
| 27 | echo -n "hello" | muse hash-object --stdin |
| 28 | cat large_file.bin | muse hash-object --stdin --write |
| 29 | """ |
| 30 | |
| 31 | import argparse |
| 32 | import json |
| 33 | import logging |
| 34 | import pathlib |
| 35 | import sys |
| 36 | from collections.abc import Callable |
| 37 | from typing import TypedDict |
| 38 | |
| 39 | from muse.core.types import blob_id |
| 40 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 41 | from muse.core.errors import ExitCode |
| 42 | from muse.core.object_store import write_object, write_object_from_path |
| 43 | from muse.core.repo import find_repo_root |
| 44 | from muse.core.snapshot import hash_file |
| 45 | from muse.core.validation import sanitize_display |
| 46 | from muse.core.timing import start_timer |
| 47 | |
| 48 | logger = logging.getLogger(__name__) |
| 49 | |
| 50 | _FORMAT_CHOICES = ("json", "text") |
| 51 | |
| 52 | class _HashObjectJson(EnvelopeJson): |
| 53 | """JSON output for ``muse hash-object --json``. |
| 54 | |
| 55 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 56 | |
| 57 | Fields |
| 58 | ------ |
| 59 | object_id Full content-addressed object ID: ``sha256:<64 hex chars>``. |
| 60 | Deterministic — identical bytes always produce the same ID. |
| 61 | stored True when the object was written to ``.muse/objects/`` (i.e. |
| 62 | ``--write`` was passed and the object was not already present). |
| 63 | size_bytes Size of the hashed input in bytes. |
| 64 | """ |
| 65 | |
| 66 | object_id: str |
| 67 | stored: bool |
| 68 | size_bytes: int |
| 69 | |
| 70 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 71 | """Register the hash-object subcommand.""" |
| 72 | parser = subparsers.add_parser( |
| 73 | "hash-object", |
| 74 | help="SHA-256 a file; optionally store it in the object store.", |
| 75 | description=__doc__, |
| 76 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 77 | ) |
| 78 | parser.add_argument( |
| 79 | "path", |
| 80 | type=pathlib.Path, |
| 81 | nargs="?", |
| 82 | default=None, |
| 83 | help="File to hash. Omit when using --stdin.", |
| 84 | ) |
| 85 | parser.add_argument( |
| 86 | "--stdin", |
| 87 | action="store_true", |
| 88 | help="Read content from stdin instead of a file path.", |
| 89 | ) |
| 90 | parser.add_argument( |
| 91 | "--write", "-w", |
| 92 | action="store_true", |
| 93 | help="Store the object in .muse/objects/.", |
| 94 | ) |
| 95 | parser.add_argument( |
| 96 | "--json", "-j", |
| 97 | action="store_true", |
| 98 | dest="json_out", |
| 99 | help="Emit machine-readable JSON.", |
| 100 | ) |
| 101 | parser.set_defaults(func=run) |
| 102 | |
| 103 | def _hash_bytes(data: bytes) -> str: |
| 104 | """Return the canonical ``sha256:<hex>`` object ID for *data*.""" |
| 105 | return blob_id(data) |
| 106 | |
| 107 | def run(args: argparse.Namespace) -> None: |
| 108 | """Compute the SHA-256 object ID of a file or stdin stream. |
| 109 | |
| 110 | The object ID is deterministic — identical bytes always produce the same ID. |
| 111 | Use ``--write`` to store the object so it can be referenced by future |
| 112 | ``muse commit-tree`` calls; use ``--stdin`` to hash content piped from |
| 113 | another command without writing a temporary file. |
| 114 | |
| 115 | Agent quickstart |
| 116 | ---------------- |
| 117 | :: |
| 118 | |
| 119 | muse hash-object file.py --format json |
| 120 | muse hash-object file.py --write --format json |
| 121 | echo "content" | muse hash-object --stdin --format json |
| 122 | muse hash-object file.py --stdin --write --format json |
| 123 | |
| 124 | JSON fields |
| 125 | ----------- |
| 126 | object_id Full ``sha256:<hex>`` object ID. |
| 127 | stored ``true`` if the object was written to the store (``--write``). |
| 128 | size_bytes Byte length of the content hashed. |
| 129 | |
| 130 | Exit codes |
| 131 | ---------- |
| 132 | 0 Success. |
| 133 | 1 Invalid arguments or conflicting flags. |
| 134 | 3 I/O error reading file or writing to the object store. |
| 135 | """ |
| 136 | elapsed = start_timer() |
| 137 | json_out: bool = args.json_out |
| 138 | path: pathlib.Path | None = args.path |
| 139 | use_stdin: bool = args.stdin |
| 140 | write: bool = args.write |
| 141 | |
| 142 | if use_stdin and path is not None: |
| 143 | print("❌ Cannot use both a path argument and --stdin.", file=sys.stderr) |
| 144 | raise SystemExit(ExitCode.USER_ERROR) |
| 145 | |
| 146 | if not use_stdin and path is None: |
| 147 | print("❌ Provide a file path or use --stdin.", file=sys.stderr) |
| 148 | raise SystemExit(ExitCode.USER_ERROR) |
| 149 | |
| 150 | # ── stdin mode ─────────────────────────────────────────────────────────── |
| 151 | if use_stdin: |
| 152 | data = sys.stdin.buffer.read() |
| 153 | object_id = _hash_bytes(data) |
| 154 | size_bytes = len(data) |
| 155 | stored = False |
| 156 | |
| 157 | if write: |
| 158 | root = find_repo_root(pathlib.Path.cwd()) |
| 159 | if root is None: |
| 160 | print( |
| 161 | "❌ Not inside a Muse repository. Cannot write object.", |
| 162 | file=sys.stderr, |
| 163 | ) |
| 164 | raise SystemExit(ExitCode.USER_ERROR) |
| 165 | try: |
| 166 | stored = write_object(root, object_id, data) |
| 167 | except ValueError as exc: |
| 168 | print( |
| 169 | f"❌ Integrity check failed: {sanitize_display(str(exc))}", |
| 170 | file=sys.stderr, |
| 171 | ) |
| 172 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 173 | except OSError as exc: |
| 174 | print( |
| 175 | f"❌ Failed to write object: {sanitize_display(str(exc))}", |
| 176 | file=sys.stderr, |
| 177 | ) |
| 178 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 179 | |
| 180 | _emit(json_out, object_id, stored, size_bytes, elapsed) |
| 181 | return |
| 182 | |
| 183 | # ── file mode ──────────────────────────────────────────────────────────── |
| 184 | assert path is not None |
| 185 | |
| 186 | # Null bytes are not valid in POSIX paths; pathlib raises ValueError on |
| 187 | # Python ≥3.12 but the explicit check is clearer and version-independent. |
| 188 | try: |
| 189 | _path_str = str(path) |
| 190 | except Exception: |
| 191 | _path_str = repr(path) |
| 192 | if "\x00" in _path_str: |
| 193 | print("❌ Path contains a null byte.", file=sys.stderr) |
| 194 | raise SystemExit(ExitCode.USER_ERROR) |
| 195 | |
| 196 | if not path.exists(): |
| 197 | print( |
| 198 | f"❌ Path does not exist: {sanitize_display(str(path))}", |
| 199 | file=sys.stderr, |
| 200 | ) |
| 201 | raise SystemExit(ExitCode.USER_ERROR) |
| 202 | if path.is_dir(): |
| 203 | print( |
| 204 | f"❌ Path is a directory, not a file: {sanitize_display(str(path))}", |
| 205 | file=sys.stderr, |
| 206 | ) |
| 207 | raise SystemExit(ExitCode.USER_ERROR) |
| 208 | # Reject special files (block/char devices, named pipes, sockets). |
| 209 | # These would either block forever (FIFO), return device-specific data |
| 210 | # (block/char), or fail unpredictably (socket). Only regular files and |
| 211 | # symlinks to regular files are accepted — consistent with git hash-object. |
| 212 | if not path.is_file(): |
| 213 | print( |
| 214 | f"❌ Path is not a regular file (device, pipe, or socket): " |
| 215 | f"{sanitize_display(str(path))}", |
| 216 | file=sys.stderr, |
| 217 | ) |
| 218 | raise SystemExit(ExitCode.USER_ERROR) |
| 219 | |
| 220 | object_id = hash_file(path) |
| 221 | size_bytes = path.stat().st_size |
| 222 | stored = False |
| 223 | |
| 224 | if write: |
| 225 | root = find_repo_root(pathlib.Path.cwd()) |
| 226 | if root is None: |
| 227 | print("❌ Not inside a Muse repository. Cannot write object.", file=sys.stderr) |
| 228 | raise SystemExit(ExitCode.USER_ERROR) |
| 229 | try: |
| 230 | # write_object_from_path streams at 64 KiB chunks — heap-safe for |
| 231 | # arbitrarily large blobs. Re-verifies hash before writing to catch |
| 232 | # any TOCTOU race between hash_file() above and the store write. |
| 233 | stored = write_object_from_path(root, object_id, path) |
| 234 | except ValueError as exc: |
| 235 | print( |
| 236 | f"❌ Integrity check failed (file may have changed during write): " |
| 237 | f"{sanitize_display(str(exc))}", |
| 238 | file=sys.stderr, |
| 239 | ) |
| 240 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 241 | except OSError as exc: |
| 242 | print( |
| 243 | f"❌ Failed to write object: {sanitize_display(str(exc))}", |
| 244 | file=sys.stderr, |
| 245 | ) |
| 246 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 247 | |
| 248 | _emit(json_out, object_id, stored, size_bytes, elapsed) |
| 249 | |
| 250 | def _emit(json_out: bool, object_id: str, stored: bool, size_bytes: int, elapsed: Callable[[], float]) -> None: |
| 251 | """Print the result in the requested format.""" |
| 252 | if not json_out: |
| 253 | print(object_id) |
| 254 | else: |
| 255 | print(json.dumps(_HashObjectJson( |
| 256 | **make_envelope(elapsed), |
| 257 | object_id=object_id, |
| 258 | stored=stored, |
| 259 | size_bytes=size_bytes, |
| 260 | ))) |
File History
1 commit
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50
docs: add symlog (#53) and reflog (#54) follow-up issue files
Sonnet 4.6
20 days ago