gabriel / muse public
plugin.py python
205 lines 7.7 KB
Raw
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 17 hours ago
1 """Todo domain plugin — a flat, content-addressed set of tasks.
2
3 Built live in Build With Muse, Episode 06, starting from
4 ``muse/plugins/scaffold``. Implements only the required six-method core
5 protocol (no address-keyed merge extension, no CRDT) — the simplest
6 possible real domain: one tracked file, ``todo.txt``, one task per line.
7
8 Tasks are addressed by their own content, not by line position. Two
9 branches adding two different tasks never conflict, no matter what —
10 that's the whole point of an address-keyed set.
11 """
12
13 import hashlib
14 import os
15 import pathlib
16 import stat as _stat
17
18 from muse._version import __version__
19 from muse.core.object_store import read_object, write_object
20 from muse.core.schema import DomainSchema, SetSchema
21 from muse.core.types import Manifest, blob_id
22 from muse.domain import (
23 AddressedDeleteOp,
24 AddressedInsertOp,
25 DriftReport,
26 LiveState,
27 MergeResult,
28 PatchOp,
29 SnapshotManifest,
30 StateDelta,
31 StateSnapshot,
32 StructuredDelta,
33 )
34
35 _DOMAIN_NAME = "todo"
36 _TASK_FILE = "todo.txt"
37
38
39 def _task_address(task: str) -> str:
40 digest = hashlib.sha256(task.encode("utf-8")).hexdigest()[:12]
41 return f"{_TASK_FILE}::{digest}"
42
43
44 def _task_content_id(task: str) -> str:
45 return blob_id(task.encode("utf-8"))
46
47
48 def _load_tasks(repo_root: pathlib.Path | None, snap: StateSnapshot) -> set[str]:
49 object_id = snap["files"].get(_TASK_FILE)
50 if object_id is None or repo_root is None:
51 return set()
52 raw = read_object(repo_root, object_id)
53 if raw is None:
54 # Uncommitted working-tree state: snapshot() hashes the live file
55 # but never writes it to the object store (only `commit` does that,
56 # for whichever paths actually get committed) -- so for drift/diff
57 # against live state, fall back to reading the file straight off
58 # disk rather than the store.
59 live_path = repo_root / _TASK_FILE
60 if not live_path.is_file():
61 return set()
62 raw = live_path.read_bytes()
63 return {line for line in raw.decode("utf-8").splitlines() if line.strip()}
64
65
66 def _write_tasks(repo_root: pathlib.Path, tasks: set[str]) -> str:
67 body = "\n".join(sorted(tasks))
68 if body:
69 body += "\n"
70 raw = body.encode("utf-8")
71 object_id = blob_id(raw)
72 write_object(repo_root, object_id, raw)
73 return object_id
74
75
76 class TodoPlugin:
77 """A flat todo list. One file, one task per line, content-addressed."""
78
79 def snapshot(self, live_state: LiveState) -> StateSnapshot:
80 if isinstance(live_state, pathlib.Path):
81 workdir = live_state
82 todo_path = workdir / _TASK_FILE
83 files: Manifest = {}
84 if todo_path.is_file():
85 st = os.lstat(todo_path)
86 if _stat.S_ISREG(st.st_mode):
87 raw = todo_path.read_bytes()
88 files[_TASK_FILE] = blob_id(raw)
89 return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[])
90 return live_state
91
92 def diff(
93 self,
94 base: StateSnapshot,
95 target: StateSnapshot,
96 *,
97 repo_root: pathlib.Path | None = None,
98 ) -> StateDelta:
99 base_oid = base["files"].get(_TASK_FILE)
100 target_oid = target["files"].get(_TASK_FILE)
101 if base_oid == target_oid:
102 return StructuredDelta(domain=_DOMAIN_NAME, ops=[], summary="no change")
103
104 base_tasks = _load_tasks(repo_root, base)
105 target_tasks = _load_tasks(repo_root, target)
106
107 # Task-level detail, nested inside the file-level op below. This is
108 # what makes the diff *structured* rather than "todo.txt changed" --
109 # each task is its own address, so two different additions never
110 # collide, no matter which file they both happen to live in.
111 child_ops: list[AddressedInsertOp | AddressedDeleteOp] = []
112 for task in sorted(target_tasks - base_tasks):
113 child_ops.append(AddressedInsertOp(
114 op="insert", address=_task_address(task),
115 content_id=_task_content_id(task), content_summary=f"added: {task}",
116 ))
117 for task in sorted(base_tasks - target_tasks):
118 child_ops.append(AddressedDeleteOp(
119 op="delete", address=_task_address(task),
120 content_id=_task_content_id(task), content_summary=f"removed: {task}",
121 ))
122
123 added = len(target_tasks - base_tasks)
124 removed = len(base_tasks - target_tasks)
125 summary = f"{added} task(s) added, {removed} task(s) removed"
126
127 # The outer op is keyed by the *file* (todo.txt) -- this is what
128 # `muse checkout` actually reads to know which object to restore or
129 # delete on disk. The task-level child_ops above are for display and
130 # merge reasoning; checkout only ever looks at the outer address.
131 if base_oid is None:
132 ops = [PatchOp(
133 op="patch", address=_TASK_FILE, child_ops=child_ops,
134 child_domain=_DOMAIN_NAME, child_summary=summary, file_change="added",
135 )]
136 elif target_oid is None:
137 ops = [PatchOp(
138 op="patch", address=_TASK_FILE, child_ops=child_ops,
139 child_domain=_DOMAIN_NAME, child_summary=summary, file_change="deleted",
140 )]
141 else:
142 ops = [PatchOp(
143 op="patch", address=_TASK_FILE, child_ops=child_ops,
144 child_domain=_DOMAIN_NAME, child_summary=summary, file_change="modified",
145 )]
146
147 return StructuredDelta(domain=_DOMAIN_NAME, ops=ops, summary=summary)
148
149 def merge(
150 self,
151 base: StateSnapshot,
152 left: StateSnapshot,
153 right: StateSnapshot,
154 *,
155 repo_root: pathlib.Path | None = None,
156 ) -> MergeResult:
157 base_tasks = _load_tasks(repo_root, base)
158 left_tasks = _load_tasks(repo_root, left)
159 right_tasks = _load_tasks(repo_root, right)
160
161 removed = (base_tasks - left_tasks) | (base_tasks - right_tasks)
162 merged_tasks = (base_tasks | left_tasks | right_tasks) - removed
163
164 if repo_root is not None:
165 object_id = _write_tasks(repo_root, merged_tasks)
166 files: Manifest = {_TASK_FILE: object_id} if merged_tasks else {}
167 else:
168 files = {}
169
170 # Address-keyed elements never conflict with each other — only a
171 # true same-address edit could, and this domain has no edit op,
172 # only add/remove of whole, content-addressed tasks.
173 return MergeResult(
174 merged=SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[]),
175 conflicts=[],
176 )
177
178 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
179 current = self.snapshot(live)
180 repo_root = live if isinstance(live, pathlib.Path) else None
181 delta = self.diff(committed, current, repo_root=repo_root)
182 return DriftReport(
183 has_drift=len(delta["ops"]) > 0,
184 summary=delta["summary"],
185 delta=delta,
186 )
187
188 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
189 # The core engine already restores todo.txt's bytes from the object
190 # store during checkout — nothing domain-specific to do here.
191 return live_state
192
193 def schema(self) -> DomainSchema:
194 return DomainSchema(
195 domain=_DOMAIN_NAME,
196 description=(
197 "A flat todo list. Tasks are content-addressed, unordered "
198 "elements in a single file (todo.txt) — order never matters, "
199 "so two people adding two different tasks never conflicts."
200 ),
201 top_level=SetSchema(kind="set", element_type="task", identity="by_content"),
202 dimensions=[],
203 merge_mode="three_way",
204 schema_version=__version__,
205 )
File History 1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 17 hours ago