gabriel / muse public
state.py python
238 lines 8.3 KB
Raw
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d fix: migration must never update identity.toml on a failed … Sonnet 5 minor ⚠ breaking 11 hours 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 ``exported_manifest_paths`` additionally tracks the set of relative
100 paths written by the most recent successful ``git-export`` run — one
101 flat record per Muse repo, matching ``_LastExportState``'s existing
102 single-target assumption (not keyed per ``--git-dir``; a repo bridging
103 to multiple git targets gets ownership tracking scoped to whichever
104 target was exported to most recently). This is the ownership record
105 ``GitExporter.sync_to_git``'s delete pass uses to compute delete
106 candidates as ``owned_before - owned_now`` — never an unconditional
107 sweep of the whole working tree. See issue #65.
108 """
109
110 imported_stashes: list[str]
111 exported_shelf_ids: list[str]
112 exported_manifest_paths: list[str]
113
114
115 class DriftInfo(TypedDict):
116 """Commit drift between git and Muse since last bridge sync."""
117
118 git_commits_since_import: int | None
119 muse_commits_since_export: int | None
120
121
122 # ---------------------------------------------------------------------------
123 # Internal lock
124 # ---------------------------------------------------------------------------
125
126 _BRIDGE_STATE_LOCK = threading.Lock()
127
128
129 # ---------------------------------------------------------------------------
130 # read_bridge_state / write_bridge_state
131 # ---------------------------------------------------------------------------
132
133 def read_bridge_state(repo_root: pathlib.Path) -> BridgeState:
134 """Read bridge state from ``<repo_root>/.muse/git-bridge.toml``.
135
136 Returns a :class:`BridgeState` with both ``last_import`` and
137 ``last_export`` set to empty dicts when the file does not exist or when
138 the corresponding TOML section is absent. Unknown keys are passed through
139 unchanged so that future fields survive a round-trip through older CLI
140 versions.
141
142 Args:
143 repo_root: Root directory of the Muse repository (must contain ``.muse/``).
144
145 Returns:
146 A :class:`BridgeState` dict, always with both top-level keys present.
147
148 Raises:
149 Nothing — missing or empty files return an empty default state.
150 """
151 import tomllib
152
153 path = git_bridge_state_path(repo_root)
154 raw = {}
155 if path.exists():
156 try:
157 with _BRIDGE_STATE_LOCK:
158 raw = tomllib.loads(path.read_text(encoding="utf-8"))
159 except Exception: # noqa: BLE001 — tolerate malformed TOML
160 raw = {}
161
162 state: BridgeState = {
163 "last_import": raw.get("last_import", {}),
164 "last_export": raw.get("last_export", {}),
165 }
166 return state
167
168
169 def write_bridge_state(repo_root: pathlib.Path, state: BridgeState) -> None:
170 """Write bridge state to ``<repo_root>/.muse/git-bridge.toml``.
171
172 Validates that any ``muse_commit_id`` value in *state* carries the
173 canonical ``sha256:`` prefix. Raises :class:`ValueError` for bare hex
174 strings to enforce the prefix invariant at the persistence boundary.
175
176 Thread-safe: uses a module-level lock so concurrent writes do not
177 interleave partial TOML output.
178
179 Args:
180 repo_root: Root directory of the Muse repository.
181 state: A :class:`BridgeState`-compatible dict to persist.
182
183 Raises:
184 ValueError: If any ``muse_commit_id`` value lacks the ``sha256:`` prefix.
185 """
186 for section_key in ("last_import", "last_export"):
187 section = state.get(section_key, {})
188 cid = section.get("muse_commit_id", "")
189 if cid:
190 if not cid.startswith("sha256:"):
191 raise ValueError(
192 f"bridge state muse_commit_id {cid!r} in section {section_key!r} "
193 "must carry a 'sha256:' prefix (e.g. sha256:<64-hex>). "
194 "Never store bare hex strings in bridge state."
195 )
196 try:
197 split_id(cid)
198 except ValueError as exc:
199 raise ValueError(
200 f"bridge state muse_commit_id {cid!r} in section {section_key!r} "
201 "is not a valid sha256: object ID."
202 ) from exc
203
204 path = git_bridge_state_path(repo_root)
205 path.parent.mkdir(parents=True, exist_ok=True)
206
207 toml_text = _dict_to_toml(state)
208
209 with _BRIDGE_STATE_LOCK:
210 tmp = path.with_suffix(".toml.tmp")
211 tmp.write_text(toml_text, encoding="utf-8")
212 tmp.replace(path)
213
214
215 def _dict_to_toml(d: BridgeState) -> str:
216 """Serialise a shallow dict-of-dicts to TOML.
217
218 Handles one level of nesting (``[section]`` headers) and the following
219 value types: ``str``, ``int``, ``bool``, ``float``. Sufficient for the
220 :class:`BridgeState` structure. Not a general-purpose TOML serialiser.
221 """
222 lines: list[str] = []
223 for section, values in d.items():
224 if not isinstance(values, dict):
225 continue
226 lines.append(f"[{section}]")
227 for key, val in values.items():
228 if isinstance(val, bool):
229 lines.append(f"{key} = {str(val).lower()}")
230 elif isinstance(val, int):
231 lines.append(f"{key} = {val}")
232 elif isinstance(val, float):
233 lines.append(f"{key} = {val!r}")
234 elif isinstance(val, str):
235 escaped = val.replace("\\", "\\\\").replace('"', '\\"')
236 lines.append(f'{key} = "{escaped}"')
237 lines.append("")
238 return "\n".join(lines)
File History 1 commit
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d fix: migration must never update identity.toml on a failed … Sonnet 5 minor 11 hours ago