gabriel / muse public
hash_object.py python
260 lines 9.0 KB
Raw
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