gabriel / muse public
ls_files.py python
243 lines 7.7 KB
Raw
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 3 days ago
1 """muse ls-files — list tracked files in a snapshot.
2
3 Lists every file tracked in a commit's snapshot, along with the SHA-256
4 object ID of its content. Defaults to the HEAD commit of the current branch.
5
6 Output (JSON, default)::
7
8 {
9 "status": "ok",
10 "error": "",
11 "commit_id": "sha256:…",
12 "snapshot_id": "sha256:…",
13 "branch": "main",
14 "path_prefix": null,
15 "file_count": 3,
16 "files": [
17 {"path": "tracks/drums.mid", "object_id": "sha256:…"},
18 ...
19 ],
20 "duration_ms": 1.2,
21 "exit_code": 0
22 }
23
24 All keys are always present so agents can read them without ``dict.get``
25 guards. ``"status"`` is always ``"ok"`` on success. ``"branch"`` is
26 ``null`` when ``--commit`` is given explicitly (no branch resolution occurs).
27 ``"path_prefix"`` is ``null`` when no ``--path-prefix`` filter was applied.
28
29 JSON error schema (exit non-zero)::
30
31 {
32 "status": "error",
33 "error": "<human-readable message>",
34 "exit_code": 1
35 }
36
37 When ``--json`` is active all errors go to stdout as JSON — no prose on
38 stderr. Agents should parse stdout and check ``status``.
39
40 Output (--format text)::
41
42 <sha256:object_id>\\t<path>
43 ...
44
45 Output contract
46 ---------------
47
48 - Exit 0: manifest listed successfully.
49 - Exit 1: commit or snapshot not found, invalid argument, or unknown --format.
50
51 Agent use
52 ---------
53
54 Filter to a subdirectory to avoid pulling the full manifest::
55
56 muse ls-files --path-prefix src/
57 muse ls-files --path-prefix src/ --commit <sha256:…>
58 """
59
60 import argparse
61 import json
62 import logging
63 import sys
64 from typing import TypedDict
65
66 from muse.core.envelope import EnvelopeJson, make_envelope
67 from muse.core.errors import ExitCode
68 from muse.core.repo import require_repo
69 from muse.core.refs import (
70 get_head_commit_id,
71 read_current_branch,
72 )
73 from muse.core.commits import read_commit
74 from muse.core.snapshots import get_commit_snapshot_manifest
75 from muse.core.validation import sanitize_display, validate_object_id, validate_path_prefix
76 from muse.core.timing import start_timer
77
78 logger = logging.getLogger(__name__)
79
80 class _LsFileEntry(TypedDict):
81 path: str
82 object_id: str
83
84 class _LsFilesJson(EnvelopeJson):
85 """Stable JSON envelope for ``muse ls-files --json``.
86
87 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
88
89 All keys are always present so agents can read them without ``dict.get``
90 guards. ``status`` is ``"ok"`` on success.
91 """
92 status: str # "ok"
93 error: str # always "" on success
94 commit_id: str
95 snapshot_id: str | None
96 branch: str | None # null when --commit was supplied explicitly
97 path_prefix: str | None # null when no --path-prefix filter applied
98 file_count: int
99 files: list[_LsFileEntry]
100
101 class _LsFilesErrorJson(EnvelopeJson):
102 """Error payload for ``muse ls-files --json`` on usage or internal errors."""
103 status: str # "error"
104 error: str
105
106 def _emit_error(json_out: bool, msg: str, code: ExitCode, elapsed: float) -> None:
107 """Print an error and raise SystemExit. Never returns.
108
109 In ``--json`` mode the error goes to stdout as a JSON payload so machine
110 consumers always get parseable output. In text mode it goes to stderr.
111 """
112 if json_out:
113 print(json.dumps(_LsFilesErrorJson(
114 **make_envelope(elapsed, exit_code=int(code)),
115 status="error",
116 error=msg,
117 )))
118 else:
119 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
120 raise SystemExit(code)
121
122 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
123 """Register the ls-files subcommand."""
124 parser = subparsers.add_parser(
125 "ls-files",
126 help="List all tracked files and their object IDs in a snapshot.",
127 description=__doc__,
128 formatter_class=argparse.RawDescriptionHelpFormatter,
129 )
130 parser.add_argument(
131 "--commit", "-c",
132 default=None,
133 dest="commit",
134 metavar="COMMIT_ID",
135 help="Commit ID to read (default: HEAD).",
136 )
137 parser.add_argument(
138 "--path-prefix", "-p",
139 default=None,
140 dest="path_prefix",
141 metavar="PREFIX",
142 help="Only list files whose path starts with PREFIX.",
143 )
144 parser.add_argument(
145 "--json", "-j",
146 action="store_true",
147 dest="json_out",
148 help="Emit machine-readable JSON.",
149 )
150 parser.set_defaults(func=run)
151
152 def run(args: argparse.Namespace) -> None:
153 """List all tracked files and their object IDs in a snapshot.
154
155 Reads the snapshot manifest of the given commit (or HEAD) and emits each
156 tracked file path together with its content-addressed object ID. Use
157 ``--path-prefix`` to scope the listing to a subdirectory — always prefer
158 this over pulling the full manifest and filtering client-side.
159
160 Agent quickstart
161 ----------------
162 ::
163
164 muse ls-files --json
165 muse ls-files --path-prefix src/ --json
166 muse ls-files --commit sha256:<id> --json
167 muse ls-files --path-prefix muse/core/ --commit HEAD --json
168
169 JSON fields
170 -----------
171 status ``"ok"`` on success.
172 commit_id Commit ID whose snapshot was read.
173 snapshot_id Content-addressed snapshot object ID.
174 branch Branch name, or ``null`` when ``--commit`` was given explicitly.
175 path_prefix The ``--path-prefix`` filter applied, or ``null``.
176 file_count Number of files returned.
177 files List of ``{"path": str, "object_id": str}`` entries.
178
179 Exit codes
180 ----------
181 0 Success.
182 1 Commit or snapshot not found, invalid argument, or unknown ``--format``.
183 2 Not inside a Muse repository.
184 """
185 elapsed = start_timer()
186
187 json_out: bool = args.json_out
188 commit: str | None = args.commit
189 path_prefix: str | None = args.path_prefix
190
191 root = require_repo()
192
193 branch: str | None = None
194
195 if commit is None:
196 branch = read_current_branch(root)
197 commit_id = get_head_commit_id(root, branch)
198 if commit_id is None:
199 _emit_error(json_out, "No commits on current branch.", ExitCode.USER_ERROR, elapsed)
200 else:
201 try:
202 validate_object_id(commit)
203 except ValueError as exc:
204 _emit_error(json_out, f"Invalid commit ID: {exc}", ExitCode.USER_ERROR, elapsed)
205 commit_id = commit
206
207 commit_record = read_commit(root, commit_id)
208 if commit_record is None:
209 _emit_error(json_out, f"Commit not found: {commit_id}", ExitCode.USER_ERROR, elapsed)
210
211 manifest = get_commit_snapshot_manifest(root, commit_id)
212 if manifest is None:
213 _emit_error(json_out, f"Snapshot not found for commit: {commit_id}", ExitCode.USER_ERROR, elapsed)
214
215 if path_prefix is not None:
216 try:
217 validate_path_prefix(path_prefix)
218 except ValueError as exc:
219 _emit_error(json_out, f"Invalid --path-prefix: {exc}", ExitCode.USER_ERROR, elapsed)
220
221 items = sorted(manifest.items())
222 if path_prefix is not None:
223 items = [(p, oid) for p, oid in items if p.startswith(path_prefix)]
224
225 files = [{"path": p, "object_id": oid} for p, oid in items]
226
227 if not json_out:
228 for entry in files:
229 # sanitize_display guards against ANSI sequences in file paths —
230 # valid on most filesystems but dangerous when echoed to a terminal.
231 print(f"{entry['object_id']}\t{sanitize_display(entry['path'])}")
232 return
233 print(json.dumps(_LsFilesJson(
234 **make_envelope(elapsed),
235 status="ok",
236 error="",
237 commit_id=commit_id,
238 snapshot_id=commit_record.snapshot_id,
239 branch=branch,
240 path_prefix=path_prefix,
241 file_count=len(files),
242 files=files,
243 )))
File History 1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 3 days ago