gabriel / muse public
state.py python
226 lines 7.7 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 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 8 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago