gabriel / muse public
state.py python
226 lines 7.7 KB
Raw
sha256:6ed6b11aceb96bef850842a7aaac54c04843fa9fba5d1200e4bc3845e12d4142 docs: add muse-tag-guide mist (complete idiomatic guide wit… Sonnet 4.6 20 days ago
1 """Bridge state persistence — ``.muse/git-bridge.toml``.
2
3 Owns the :class:`BridgeState` TypedDict family, the thread-safe
4 :func:`read_bridge_state` / :func:`write_bridge_state` pair, and the
5 minimal TOML serialiser :func:`_dict_to_toml`.
6
7 All Muse commit IDs stored in bridge state must carry the canonical
8 ``sha256:`` prefix. :func:`write_bridge_state` enforces this at the
9 persistence boundary.
10 """
11
12 from __future__ import annotations
13
14 import pathlib
15 import threading
16 from typing import TypedDict
17
18 from muse.core.paths import git_bridge_state_path
19 from muse.core.types import split_id
20
21
22 # ---------------------------------------------------------------------------
23 # Type aliases
24 # ---------------------------------------------------------------------------
25
26 SnapshotManifest = dict[str, str]
27
28 # ---------------------------------------------------------------------------
29 # Bridge state — TypedDicts
30 # ---------------------------------------------------------------------------
31
32 class _LastImportState(TypedDict, total=False):
33 """State recorded after a successful ``muse bridge git-import`` run.
34
35 Fields
36 ------
37 git_sha: 40-char SHA-1 of the last git commit imported.
38 git_ref: Git branch or tag that was imported (e.g. ``"main"``).
39 git_remote: Name of the git remote (e.g. ``"origin"``).
40 muse_branch: Muse branch the commits were written to.
41 muse_commit_id: ``sha256:`` prefixed ID of the last Muse commit written.
42 imported_at: ISO-8601 UTC timestamp of the import run.
43 commits_written: Number of Muse commits created in the last import run.
44 """
45
46 git_sha: str
47 git_ref: str
48 git_remote: str
49 muse_branch: str
50 muse_commit_id: str
51 imported_at: str
52 commits_written: int
53
54
55 class _LastExportState(TypedDict, total=False):
56 """State recorded after a successful ``muse bridge git-export`` run.
57
58 Fields
59 ------
60 muse_branch: Muse branch that was exported.
61 muse_commit_id: ``sha256:`` prefixed ID of the exported Muse commit.
62 git_remote: Name of the git remote pushed to.
63 git_ref: Git branch written to (e.g. ``"muse-mirror"``).
64 git_sha: 40-char SHA-1 of the git commit created by the export.
65 exported_at: ISO-8601 UTC timestamp of the export run.
66 """
67
68 muse_branch: str
69 muse_commit_id: str
70 git_remote: str
71 git_ref: str
72 git_sha: str
73 exported_at: str
74
75
76 class BridgeState(TypedDict):
77 """Persisted bridge synchronisation state.
78
79 Written to ``<repo_root>/.muse/git-bridge.toml`` by both ``git-import``
80 and ``git-export``. Never committed to the Muse object store — add the
81 path to ``.museignore``.
82
83 Both sub-dicts use :class:`total=False` keys so callers that only perform
84 one direction of sync can omit the unused section.
85
86 Fields
87 ------
88 last_import: Most recent git-import run details (may be empty dict).
89 last_export: Most recent git-export run details (may be empty dict).
90 """
91
92 last_import: _LastImportState
93 last_export: _LastExportState
94
95
96 class _SidecarData(TypedDict, total=False):
97 """Phase-8 sidecar JSON — tracks imported stashes and exported shelf IDs."""
98
99 imported_stashes: list[str]
100 exported_shelf_ids: list[str]
101
102
103 class DriftInfo(TypedDict):
104 """Commit drift between git and Muse since last bridge sync."""
105
106 git_commits_since_import: int | None
107 muse_commits_since_export: int | None
108
109
110 # ---------------------------------------------------------------------------
111 # Internal lock
112 # ---------------------------------------------------------------------------
113
114 _BRIDGE_STATE_LOCK = threading.Lock()
115
116
117 # ---------------------------------------------------------------------------
118 # read_bridge_state / write_bridge_state
119 # ---------------------------------------------------------------------------
120
121 def read_bridge_state(repo_root: pathlib.Path) -> BridgeState:
122 """Read bridge state from ``<repo_root>/.muse/git-bridge.toml``.
123
124 Returns a :class:`BridgeState` with both ``last_import`` and
125 ``last_export`` set to empty dicts when the file does not exist or when
126 the corresponding TOML section is absent. Unknown keys are passed through
127 unchanged so that future fields survive a round-trip through older CLI
128 versions.
129
130 Args:
131 repo_root: Root directory of the Muse repository (must contain ``.muse/``).
132
133 Returns:
134 A :class:`BridgeState` dict, always with both top-level keys present.
135
136 Raises:
137 Nothing — missing or empty files return an empty default state.
138 """
139 import tomllib
140
141 path = git_bridge_state_path(repo_root)
142 raw = {}
143 if path.exists():
144 try:
145 with _BRIDGE_STATE_LOCK:
146 raw = tomllib.loads(path.read_text(encoding="utf-8"))
147 except Exception: # noqa: BLE001 — tolerate malformed TOML
148 raw = {}
149
150 state: BridgeState = {
151 "last_import": raw.get("last_import", {}),
152 "last_export": raw.get("last_export", {}),
153 }
154 return state
155
156
157 def write_bridge_state(repo_root: pathlib.Path, state: BridgeState) -> None:
158 """Write bridge state to ``<repo_root>/.muse/git-bridge.toml``.
159
160 Validates that any ``muse_commit_id`` value in *state* carries the
161 canonical ``sha256:`` prefix. Raises :class:`ValueError` for bare hex
162 strings to enforce the prefix invariant at the persistence boundary.
163
164 Thread-safe: uses a module-level lock so concurrent writes do not
165 interleave partial TOML output.
166
167 Args:
168 repo_root: Root directory of the Muse repository.
169 state: A :class:`BridgeState`-compatible dict to persist.
170
171 Raises:
172 ValueError: If any ``muse_commit_id`` value lacks the ``sha256:`` prefix.
173 """
174 for section_key in ("last_import", "last_export"):
175 section = state.get(section_key, {})
176 cid = section.get("muse_commit_id", "")
177 if cid:
178 if not cid.startswith("sha256:"):
179 raise ValueError(
180 f"bridge state muse_commit_id {cid!r} in section {section_key!r} "
181 "must carry a 'sha256:' prefix (e.g. sha256:<64-hex>). "
182 "Never store bare hex strings in bridge state."
183 )
184 try:
185 split_id(cid)
186 except ValueError as exc:
187 raise ValueError(
188 f"bridge state muse_commit_id {cid!r} in section {section_key!r} "
189 "is not a valid sha256: object ID."
190 ) from exc
191
192 path = git_bridge_state_path(repo_root)
193 path.parent.mkdir(parents=True, exist_ok=True)
194
195 toml_text = _dict_to_toml(state)
196
197 with _BRIDGE_STATE_LOCK:
198 tmp = path.with_suffix(".toml.tmp")
199 tmp.write_text(toml_text, encoding="utf-8")
200 tmp.replace(path)
201
202
203 def _dict_to_toml(d: BridgeState) -> str:
204 """Serialise a shallow dict-of-dicts to TOML.
205
206 Handles one level of nesting (``[section]`` headers) and the following
207 value types: ``str``, ``int``, ``bool``, ``float``. Sufficient for the
208 :class:`BridgeState` structure. Not a general-purpose TOML serialiser.
209 """
210 lines: list[str] = []
211 for section, values in d.items():
212 if not isinstance(values, dict):
213 continue
214 lines.append(f"[{section}]")
215 for key, val in values.items():
216 if isinstance(val, bool):
217 lines.append(f"{key} = {str(val).lower()}")
218 elif isinstance(val, int):
219 lines.append(f"{key} = {val}")
220 elif isinstance(val, float):
221 lines.append(f"{key} = {val!r}")
222 elif isinstance(val, str):
223 escaped = val.replace("\\", "\\\\").replace('"', '\\"')
224 lines.append(f'{key} = "{escaped}"')
225 lines.append("")
226 return "\n".join(lines)
File History 1 commit
sha256:6ed6b11aceb96bef850842a7aaac54c04843fa9fba5d1200e4bc3845e12d4142 docs: add muse-tag-guide mist (complete idiomatic guide wit… Sonnet 4.6 20 days ago