gabriel / muse public
hash_object.py python
229 lines 7.7 KB
Raw
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 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 from __future__ import annotations
32
33 import argparse
34 import hashlib
35 import json
36 import logging
37 import pathlib
38 import sys
39
40 from muse.core.errors import ExitCode
41 from muse.core.object_store import write_object, write_object_from_path
42 from muse.core.repo import find_repo_root
43 from muse.core.snapshot import hash_file
44 from muse.core.validation import sanitize_display
45
46 logger = logging.getLogger(__name__)
47
48 _FORMAT_CHOICES = ("json", "text")
49
50
51 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
52 """Register the hash-object subcommand."""
53 parser = subparsers.add_parser(
54 "hash-object",
55 help="SHA-256 a file; optionally store it in the object store.",
56 description=__doc__,
57 formatter_class=argparse.RawDescriptionHelpFormatter,
58 )
59 parser.add_argument(
60 "path",
61 type=pathlib.Path,
62 nargs="?",
63 default=None,
64 help="File to hash. Omit when using --stdin.",
65 )
66 parser.add_argument(
67 "--stdin",
68 action="store_true",
69 help="Read content from stdin instead of a file path.",
70 )
71 parser.add_argument(
72 "--write", "-w",
73 action="store_true",
74 help="Store the object in .muse/objects/.",
75 )
76 parser.add_argument(
77 "--format", "-f",
78 dest="fmt",
79 default="json",
80 metavar="FORMAT",
81 help="Output format: json or text. (default: json)",
82 )
83 parser.add_argument(
84 "--json", action="store_const", const="json", dest="fmt",
85 help="Shorthand for --format json.",
86 )
87 parser.set_defaults(func=run)
88
89
90 def _hash_bytes(data: bytes) -> str:
91 """Return the SHA-256 hex digest of *data*."""
92 return hashlib.sha256(data).hexdigest()
93
94
95 def run(args: argparse.Namespace) -> None:
96 """Compute the SHA-256 object ID of a file or stdin stream.
97
98 The object ID is deterministic — identical bytes always produce the same ID.
99 Pass ``--write`` to also store the object so it can be referenced by future
100 ``muse commit-tree`` calls.
101
102 Pass ``--stdin`` to read from stdin — useful for agents that want to hash
103 content without writing a temporary file first.
104 """
105 fmt: str = args.fmt
106 path: pathlib.Path | None = args.path
107 use_stdin: bool = args.stdin
108 write: bool = args.write
109
110 if fmt not in _FORMAT_CHOICES:
111 print(
112 f"❌ Unknown format {fmt!r}. Valid choices: {', '.join(_FORMAT_CHOICES)}",
113 file=sys.stderr,
114 )
115 raise SystemExit(ExitCode.USER_ERROR)
116
117 if use_stdin and path is not None:
118 print("❌ Cannot use both a path argument and --stdin.", file=sys.stderr)
119 raise SystemExit(ExitCode.USER_ERROR)
120
121 if not use_stdin and path is None:
122 print("❌ Provide a file path or use --stdin.", file=sys.stderr)
123 raise SystemExit(ExitCode.USER_ERROR)
124
125 # ── stdin mode ───────────────────────────────────────────────────────────
126 if use_stdin:
127 data = sys.stdin.buffer.read()
128 object_id = _hash_bytes(data)
129 stored = False
130
131 if write:
132 root = find_repo_root(pathlib.Path.cwd())
133 if root is None:
134 print(
135 "❌ Not inside a Muse repository. Cannot write object.",
136 file=sys.stderr,
137 )
138 raise SystemExit(ExitCode.USER_ERROR)
139 try:
140 stored = write_object(root, object_id, data)
141 except ValueError as exc:
142 print(
143 f"❌ Integrity check failed: {sanitize_display(str(exc))}",
144 file=sys.stderr,
145 )
146 raise SystemExit(ExitCode.INTERNAL_ERROR)
147 except OSError as exc:
148 print(
149 f"❌ Failed to write object: {sanitize_display(str(exc))}",
150 file=sys.stderr,
151 )
152 raise SystemExit(ExitCode.INTERNAL_ERROR)
153
154 _emit(fmt, object_id, stored)
155 return
156
157 # ── file mode ────────────────────────────────────────────────────────────
158 assert path is not None
159
160 # Null bytes are not valid in POSIX paths; pathlib raises ValueError on
161 # Python ≥3.12 but the explicit check is clearer and version-independent.
162 try:
163 _path_str = str(path)
164 except Exception:
165 _path_str = repr(path)
166 if "\x00" in _path_str:
167 print("❌ Path contains a null byte.", file=sys.stderr)
168 raise SystemExit(ExitCode.USER_ERROR)
169
170 if not path.exists():
171 print(
172 f"❌ Path does not exist: {sanitize_display(str(path))}",
173 file=sys.stderr,
174 )
175 raise SystemExit(ExitCode.USER_ERROR)
176 if path.is_dir():
177 print(
178 f"❌ Path is a directory, not a file: {sanitize_display(str(path))}",
179 file=sys.stderr,
180 )
181 raise SystemExit(ExitCode.USER_ERROR)
182 # Reject special files (block/char devices, named pipes, sockets).
183 # These would either block forever (FIFO), return device-specific data
184 # (block/char), or fail unpredictably (socket). Only regular files and
185 # symlinks to regular files are accepted — consistent with git hash-object.
186 if not path.is_file():
187 print(
188 f"❌ Path is not a regular file (device, pipe, or socket): "
189 f"{sanitize_display(str(path))}",
190 file=sys.stderr,
191 )
192 raise SystemExit(ExitCode.USER_ERROR)
193
194 object_id = hash_file(path)
195 stored = False
196
197 if write:
198 root = find_repo_root(pathlib.Path.cwd())
199 if root is None:
200 print("❌ Not inside a Muse repository. Cannot write object.", file=sys.stderr)
201 raise SystemExit(ExitCode.USER_ERROR)
202 try:
203 # write_object_from_path streams at 64 KiB chunks — heap-safe for
204 # arbitrarily large blobs. Re-verifies hash before writing to catch
205 # any TOCTOU race between hash_file() above and the store write.
206 stored = write_object_from_path(root, object_id, path)
207 except ValueError as exc:
208 print(
209 f"❌ Integrity check failed (file may have changed during write): "
210 f"{sanitize_display(str(exc))}",
211 file=sys.stderr,
212 )
213 raise SystemExit(ExitCode.INTERNAL_ERROR)
214 except OSError as exc:
215 print(
216 f"❌ Failed to write object: {sanitize_display(str(exc))}",
217 file=sys.stderr,
218 )
219 raise SystemExit(ExitCode.INTERNAL_ERROR)
220
221 _emit(fmt, object_id, stored)
222
223
224 def _emit(fmt: str, object_id: str, stored: bool) -> None:
225 """Print the result in the requested format."""
226 if fmt == "text":
227 print(object_id)
228 else:
229 print(json.dumps({"object_id": object_id, "stored": stored}))
File History 1 commit
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago