filekeeper-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ """Content-addressed blob storage.
2
+
3
+ Immutable, SHA-256 named, written atomically and fsynced. The history layer will
4
+ call :meth:`BlobStore.put_bytes` for before-images *before* it records a pending
5
+ operation, so a lost journal record can never be the only thing standing between
6
+ a user and their previous bytes.
7
+
8
+ Blobs live at ``<state>/blobs/<first two hex>/<remaining hex>``, so a large store
9
+ does not become one directory with millions of entries.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import stat
16
+ from pathlib import Path
17
+
18
+ from ..errors import StorageError, ValidationError
19
+ from ..safety.atomic import atomic_write_bytes
20
+ from ..safety.hashing import ALGORITHM, format_digest, hash_bytes, normalize_hash
21
+
22
+
23
+ class BlobStore:
24
+ """A directory of content-addressed files."""
25
+
26
+ def __init__(self, directory: Path, *, fsync: bool = True) -> None:
27
+ self.directory = Path(directory)
28
+ self.fsync = fsync
29
+
30
+ def _relative_parts(self, digest: str) -> tuple[str, str]:
31
+ hex_digest = normalize_hash(digest).removeprefix(f"{ALGORITHM}:")
32
+ return hex_digest[:2], hex_digest[2:]
33
+
34
+ def path_for(self, digest: str) -> Path:
35
+ first, rest = self._relative_parts(digest)
36
+ return self.directory / first / rest
37
+
38
+ def has(self, digest: str) -> bool:
39
+ return self.path_for(digest).is_file()
40
+
41
+ def put_bytes(self, data: bytes) -> str:
42
+ """Store ``data`` if absent and return its canonical ``sha256:`` hash."""
43
+
44
+ digest = hash_bytes(data)
45
+ target = self.path_for(digest)
46
+ if target.is_file() and target.stat().st_size == len(data):
47
+ return digest
48
+ try:
49
+ target.parent.mkdir(parents=True, exist_ok=True)
50
+ target.parent.chmod(stat.S_IRWXU)
51
+ atomic_write_bytes(target, data, mode=0o600, fsync=self.fsync)
52
+ os.chmod(target.parent, stat.S_IRWXU)
53
+ except OSError as exc: # pragma: no cover - filesystem failure
54
+ raise StorageError(
55
+ f"cannot write blob: {exc.strerror or type(exc).__name__}",
56
+ details={"hash": digest},
57
+ ) from exc
58
+ return digest
59
+
60
+ def get_bytes(self, digest: str) -> bytes:
61
+ """Read a blob and verify its hash before returning it."""
62
+
63
+ target = self.path_for(digest)
64
+ try:
65
+ data = target.read_bytes()
66
+ except FileNotFoundError as exc:
67
+ raise StorageError(
68
+ "blob is missing from the store", details={"hash": normalize_hash(digest)}
69
+ ) from exc
70
+ except OSError as exc: # pragma: no cover - filesystem failure
71
+ raise StorageError(
72
+ f"cannot read blob: {exc.strerror or type(exc).__name__}"
73
+ ) from exc
74
+ actual = hash_bytes(data)
75
+ if actual != normalize_hash(digest):
76
+ raise StorageError(
77
+ "blob content does not match its hash; the store is corrupt",
78
+ details={"hash": normalize_hash(digest), "actual_hash": actual},
79
+ )
80
+ return data
81
+
82
+ def verify(self, digest: str) -> bool:
83
+ """True when the blob exists and its bytes still hash to its name."""
84
+
85
+ try:
86
+ self.get_bytes(digest)
87
+ except (StorageError, ValidationError):
88
+ return False
89
+ return True
90
+
91
+
92
+ __all__ = ["BlobStore", "format_digest"]
@@ -0,0 +1,430 @@
1
+ """The durable operation journal.
2
+
3
+ A mutation is recorded in this order, and the order matters:
4
+
5
+ 1. content-addressed blobs are written and fsynced (immutable, so writing them
6
+ first is always safe and never needs rollback);
7
+ 2. the operation row is inserted with status ``pending`` on a connection in
8
+ SQLite full-synchronous mode, so a lost flush cannot erase the only record of
9
+ what is about to happen;
10
+ 3. the caller replaces the target (per-file atomic);
11
+ 4. the caller verifies the resulting hash;
12
+ 5. the operation is marked ``committed``.
13
+
14
+ If the process dies anywhere after step 2, :meth:`OperationJournal.reconcile`
15
+ resolves the pending row on the next opening by comparing each affected path's
16
+ current state with the recorded before and after states. It never silently
17
+ discards the record, and it never deletes blobs: the previous bytes stay
18
+ recoverable.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sqlite3
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from .. import database
30
+ from ..config import Config
31
+ from ..errors import StorageError
32
+ from ..models import OperationItemRecord, OperationRecord
33
+ from ..safety.hashing import hash_bytes, hash_file
34
+ from .blobs import BlobStore
35
+
36
+ PENDING = "pending"
37
+ COMMITTED = "committed"
38
+ UNDONE = "undone"
39
+ FAILED = "failed"
40
+ PARTIALLY_UNDONE = "partially_undone"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class PendingItem:
45
+ """One path's before and after state, as the caller knows it.
46
+
47
+ ``before_bytes`` / ``after_bytes`` are staged into the blob store when they
48
+ are inside the size cap. A hash may be supplied without bytes when the
49
+ caller chose not to read the content (an oversize file): the row then keeps
50
+ the hash but names no blob, and undo reports that history was skipped.
51
+ """
52
+
53
+ relative_path: str
54
+ item_type: str
55
+ before_exists: bool
56
+ after_exists: bool
57
+ before_bytes: bytes | None = None
58
+ after_bytes: bytes | None = None
59
+ before_hash: str | None = None
60
+ after_hash: str | None = None
61
+ before_mode: int | None = None
62
+ after_mode: int | None = None
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class BeginResult:
67
+ operation_id: str
68
+ skipped: tuple[str, ...]
69
+ warnings: tuple[str, ...]
70
+
71
+
72
+ class OperationJournal:
73
+ """Records operations, stages blobs, and resolves interrupted operations."""
74
+
75
+ def __init__(
76
+ self,
77
+ database_path: Path,
78
+ *,
79
+ root: Path,
80
+ workspace_id: str,
81
+ config: Config,
82
+ blobs: BlobStore | None = None,
83
+ ) -> None:
84
+ self.database_path = Path(database_path)
85
+ self.root = Path(root)
86
+ self.workspace_id = workspace_id
87
+ self.config = config
88
+ self.blobs = blobs or BlobStore(self.database_path.parent / "blobs", fsync=config.fsync)
89
+
90
+ # -- connections ---------------------------------------------------------
91
+ def _connect(self) -> sqlite3.Connection:
92
+ return database.open_workspace_database(self.database_path)
93
+
94
+ # -- recording -----------------------------------------------------------
95
+ def _stage(
96
+ self, data: bytes | None, digest: str | None, skipped: list[str], path: str
97
+ ) -> str | None:
98
+ if data is None:
99
+ # A hash without bytes is only "skipped" when the store genuinely
100
+ # does not hold those bytes. An undo names hashes whose blobs the
101
+ # original operation already stored, so its history is complete.
102
+ if digest is not None and not self.blobs.has(digest):
103
+ skipped.append(path)
104
+ return digest
105
+ content_hash = hash_bytes(data)
106
+ if len(data) > self.config.max_history_blob_bytes:
107
+ skipped.append(path)
108
+ return content_hash
109
+ self.blobs.put_bytes(data)
110
+ return content_hash
111
+
112
+ def begin(
113
+ self,
114
+ *,
115
+ operation_type: str,
116
+ target_path: str | None,
117
+ summary: str,
118
+ items: list[PendingItem],
119
+ actor: str | None = None,
120
+ task_id: str | None = None,
121
+ before_hash: str | None = None,
122
+ after_hash: str | None = None,
123
+ metadata: dict[str, Any] | None = None,
124
+ ) -> BeginResult:
125
+ """Stage blobs, then insert the pending operation and its items."""
126
+
127
+ skipped: list[str] = []
128
+ staged: list[tuple[PendingItem, str | None, str | None]] = []
129
+ blob_rows: list[tuple[str, int]] = []
130
+
131
+ for item in items:
132
+ before = self._stage(item.before_bytes, item.before_hash, skipped, item.relative_path)
133
+ after = self._stage(item.after_bytes, item.after_hash, skipped, item.relative_path)
134
+ staged.append((item, before, after))
135
+ for data, digest in ((item.before_bytes, before), (item.after_bytes, after)):
136
+ if data is None or digest is None:
137
+ continue
138
+ if len(data) > self.config.max_history_blob_bytes:
139
+ continue
140
+ blob_rows.append((digest, len(data)))
141
+
142
+ operation_id = f"op_{database.uuid_token()}"
143
+ payload: dict[str, Any] = dict(metadata or {})
144
+ if skipped:
145
+ payload["history_skipped"] = sorted(set(skipped))
146
+ now = database.utc_now()
147
+
148
+ connection = self._connect()
149
+ try:
150
+ with connection:
151
+ connection.execute(
152
+ "INSERT INTO operations (id, workspace_id, operation_type, target_path, "
153
+ "status, created_at, actor, task_id, summary, before_hash, after_hash, "
154
+ "metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
155
+ (
156
+ operation_id,
157
+ self.workspace_id,
158
+ operation_type,
159
+ target_path,
160
+ PENDING,
161
+ now,
162
+ actor,
163
+ task_id,
164
+ summary,
165
+ before_hash,
166
+ after_hash,
167
+ json.dumps(payload, sort_keys=True),
168
+ ),
169
+ )
170
+ for item, before, after in staged:
171
+ connection.execute(
172
+ "INSERT INTO operation_items (operation_id, relative_path, item_type, "
173
+ "before_blob_hash, after_blob_hash, before_exists, after_exists, "
174
+ "before_mode, after_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
175
+ (
176
+ operation_id,
177
+ item.relative_path,
178
+ item.item_type,
179
+ before,
180
+ after,
181
+ int(item.before_exists),
182
+ int(item.after_exists),
183
+ item.before_mode,
184
+ item.after_mode,
185
+ ),
186
+ )
187
+ for digest, size in blob_rows:
188
+ connection.execute(
189
+ "INSERT OR IGNORE INTO blobs (hash, size, created_at) VALUES (?, ?, ?)",
190
+ (digest, size, now),
191
+ )
192
+ except sqlite3.Error as exc:
193
+ raise StorageError(
194
+ f"cannot record operation: {exc}", details={"operation_type": operation_type}
195
+ ) from exc
196
+ finally:
197
+ connection.close()
198
+
199
+ warnings = tuple(
200
+ f"history was skipped for {path}: it exceeds the history size cap "
201
+ f"({self.config.max_history_blob_bytes} bytes), so it cannot be undone"
202
+ for path in sorted(set(skipped))
203
+ )
204
+ return BeginResult(
205
+ operation_id=operation_id,
206
+ skipped=tuple(sorted(set(skipped))),
207
+ warnings=warnings,
208
+ )
209
+
210
+ def commit(self, operation_id: str) -> None:
211
+ """Mark an operation committed once its filesystem change is verified."""
212
+
213
+ self._set_status(operation_id, COMMITTED)
214
+
215
+ def abort(self, operation_id: str) -> None:
216
+ """Mark an operation failed; blobs stay behind so nothing is lost."""
217
+
218
+ self._set_status(operation_id, FAILED)
219
+
220
+ def _set_status(self, operation_id: str, status: str) -> None:
221
+ connection = self._connect()
222
+ try:
223
+ with connection:
224
+ connection.execute(
225
+ "UPDATE operations SET status = ? WHERE id = ?", (status, operation_id)
226
+ )
227
+ except sqlite3.Error as exc:
228
+ raise StorageError(
229
+ f"cannot update operation status: {exc}", details={"operation_id": operation_id}
230
+ ) from exc
231
+ finally:
232
+ connection.close()
233
+
234
+ # -- reading -------------------------------------------------------------
235
+ def get(self, operation_id: str) -> OperationRecord | None:
236
+ connection = self._connect()
237
+ try:
238
+ row = connection.execute(
239
+ "SELECT * FROM operations WHERE id = ?", (operation_id,)
240
+ ).fetchone()
241
+ if row is None:
242
+ return None
243
+ items = connection.execute(
244
+ "SELECT * FROM operation_items WHERE operation_id = ? ORDER BY id",
245
+ (operation_id,),
246
+ ).fetchall()
247
+ except sqlite3.Error as exc:
248
+ raise StorageError(
249
+ f"cannot read operation history: {exc}", details={"operation_id": operation_id}
250
+ ) from exc
251
+ finally:
252
+ connection.close()
253
+ return _row_to_operation(row, items)
254
+
255
+ def recent(self, *, limit: int = 20) -> tuple[OperationRecord, ...]:
256
+ connection = self._connect()
257
+ try:
258
+ rows = connection.execute(
259
+ "SELECT * FROM operations ORDER BY created_at DESC, rowid DESC LIMIT ?",
260
+ (limit,),
261
+ ).fetchall()
262
+ except sqlite3.Error as exc:
263
+ raise StorageError(f"cannot read operation history: {exc}") from exc
264
+ finally:
265
+ connection.close()
266
+ return tuple(_row_to_operation(row, ()) for row in rows)
267
+
268
+ # -- recovery ------------------------------------------------------------
269
+ def reconcile(self) -> tuple[str, ...]:
270
+ """Resolve operations left pending by an interrupted run.
271
+
272
+ Each affected path is compared with the recorded before and after
273
+ states. All-after means the change landed (committed); all-before means
274
+ it did not (failed); anything else is reported as partially applied and
275
+ kept as failed. Blobs are never removed, so recovery stays possible.
276
+ """
277
+
278
+ connection = self._connect()
279
+ try:
280
+ pending = connection.execute(
281
+ "SELECT * FROM operations WHERE status = ? ORDER BY created_at", (PENDING,)
282
+ ).fetchall()
283
+ notes: list[str] = []
284
+ for row in pending:
285
+ items = connection.execute(
286
+ "SELECT * FROM operation_items WHERE operation_id = ? ORDER BY id",
287
+ (row["id"],),
288
+ ).fetchall()
289
+ status, unresolved = _classify_pending(self.root, items)
290
+ metadata = _load_metadata(row["metadata_json"])
291
+ metadata["reconciled"] = status
292
+ if unresolved:
293
+ metadata["unresolved"] = unresolved
294
+ with connection:
295
+ connection.execute(
296
+ "UPDATE operations SET status = ?, metadata_json = ? WHERE id = ?",
297
+ (status, json.dumps(metadata, sort_keys=True), row["id"]),
298
+ )
299
+ detail = ""
300
+ if unresolved:
301
+ detail = f" ({len(unresolved)} path(s) match neither recorded state)"
302
+ notes.append(f"{row['id']}: pending operation resolved as {status}{detail}")
303
+ except sqlite3.Error as exc:
304
+ raise StorageError(f"cannot reconcile pending operations: {exc}") from exc
305
+ finally:
306
+ connection.close()
307
+ return tuple(notes)
308
+
309
+
310
+ def _load_metadata(raw: str | None) -> dict[str, Any]:
311
+ if not raw:
312
+ return {}
313
+ try:
314
+ loaded = json.loads(raw)
315
+ except (TypeError, ValueError):
316
+ return {}
317
+ return loaded if isinstance(loaded, dict) else {}
318
+
319
+
320
+ def _current_state(root: Path, item: sqlite3.Row | OperationItemRecord) -> tuple[bool, str | None]:
321
+ relative = (
322
+ str(item["relative_path"]) if isinstance(item, sqlite3.Row) else item.relative_path
323
+ )
324
+ path = root / relative
325
+ if str(item["item_type"] if isinstance(item, sqlite3.Row) else item.item_type) == "directory":
326
+ return path.is_dir(), None
327
+ if not path.is_file():
328
+ return False, None
329
+ try:
330
+ return True, hash_file(path, display_path=relative)
331
+ except Exception:
332
+ return False, None
333
+
334
+
335
+ def _field(row: sqlite3.Row | OperationItemRecord, name: str) -> Any:
336
+ return row[name] if isinstance(row, sqlite3.Row) else getattr(row, name)
337
+
338
+
339
+ def _matches(
340
+ exists: bool, digest: str | None, current_exists: bool, current_hash: str | None
341
+ ) -> bool:
342
+ if exists != current_exists:
343
+ return False
344
+ if not exists:
345
+ return True
346
+ if digest is None:
347
+ return True
348
+ return current_hash == digest
349
+
350
+
351
+ def _classify_pending(
352
+ root: Path, items: list[sqlite3.Row]
353
+ ) -> tuple[str, list[str]]:
354
+ if not items:
355
+ return FAILED, []
356
+ all_after = True
357
+ all_before = True
358
+ unresolved: list[str] = []
359
+ for item in items:
360
+ current_exists, current_hash = _current_state(root, item)
361
+ if not _matches(
362
+ bool(item["after_exists"]), item["after_blob_hash"], current_exists, current_hash
363
+ ):
364
+ all_after = False
365
+ if not _matches(
366
+ bool(item["before_exists"]), item["before_blob_hash"], current_exists, current_hash
367
+ ):
368
+ all_before = False
369
+ after_matches = _matches(
370
+ bool(item["after_exists"]), item["after_blob_hash"], current_exists, current_hash
371
+ )
372
+ before_matches = _matches(
373
+ bool(item["before_exists"]), item["before_blob_hash"], current_exists, current_hash
374
+ )
375
+ if not (after_matches or before_matches):
376
+ unresolved.append(str(item["relative_path"]))
377
+ if all_after:
378
+ return COMMITTED, []
379
+ if all_before:
380
+ return FAILED, []
381
+ return FAILED, unresolved
382
+
383
+
384
+ def _row_to_operation(row: sqlite3.Row, items: Any) -> OperationRecord:
385
+ metadata = _load_metadata(row["metadata_json"])
386
+ skipped = metadata.get("history_skipped", [])
387
+ return OperationRecord(
388
+ operation_id=str(row["id"]),
389
+ workspace_id=str(row["workspace_id"]),
390
+ operation_type=str(row["operation_type"]),
391
+ target_path=None if row["target_path"] is None else str(row["target_path"]),
392
+ status=str(row["status"]),
393
+ created_at=str(row["created_at"]),
394
+ actor=None if row["actor"] is None else str(row["actor"]),
395
+ task_id=None if row["task_id"] is None else str(row["task_id"]),
396
+ summary=None if row["summary"] is None else str(row["summary"]),
397
+ before_hash=None if row["before_hash"] is None else str(row["before_hash"]),
398
+ after_hash=None if row["after_hash"] is None else str(row["after_hash"]),
399
+ items=tuple(
400
+ OperationItemRecord(
401
+ relative_path=str(item["relative_path"]),
402
+ item_type=str(item["item_type"]),
403
+ before_blob_hash=None
404
+ if item["before_blob_hash"] is None
405
+ else str(item["before_blob_hash"]),
406
+ after_blob_hash=None
407
+ if item["after_blob_hash"] is None
408
+ else str(item["after_blob_hash"]),
409
+ before_exists=bool(item["before_exists"]),
410
+ after_exists=bool(item["after_exists"]),
411
+ before_mode=None if item["before_mode"] is None else int(item["before_mode"]),
412
+ after_mode=None if item["after_mode"] is None else int(item["after_mode"]),
413
+ )
414
+ for item in items
415
+ ),
416
+ history_skipped=tuple(str(path) for path in skipped),
417
+ metadata=metadata,
418
+ )
419
+
420
+
421
+ __all__ = [
422
+ "COMMITTED",
423
+ "FAILED",
424
+ "PARTIALLY_UNDONE",
425
+ "PENDING",
426
+ "UNDONE",
427
+ "BeginResult",
428
+ "OperationJournal",
429
+ "PendingItem",
430
+ ]
@@ -0,0 +1,152 @@
1
+ """Read-side history surfaces: one operation, a filtered list, a unified diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from typing import Any
7
+
8
+ from ..diffing import unified_diff_text
9
+ from ..errors import HistoryUnavailableError, OperationNotFoundError, StorageError
10
+ from ..models import DiffEntry, OperationDiff, OperationItemRecord, OperationRecord
11
+ from .journal import OperationJournal, _row_to_operation
12
+
13
+
14
+ def get_operation(journal: OperationJournal, operation_id: str) -> OperationRecord:
15
+ record = journal.get(operation_id)
16
+ if record is None:
17
+ raise OperationNotFoundError(
18
+ "no operation with that id in this workspace", details={"operation_id": operation_id}
19
+ )
20
+ return record
21
+
22
+
23
+ def _matching_operations(journal: OperationJournal, path: str | None) -> list[str]:
24
+ connection = journal._connect()
25
+ try:
26
+ if path is None:
27
+ rows = connection.execute("SELECT id FROM operations").fetchall()
28
+ else:
29
+ rows = connection.execute(
30
+ "SELECT DISTINCT o.id FROM operations o "
31
+ "LEFT JOIN operation_items i ON i.operation_id = o.id "
32
+ "WHERE o.target_path = ? OR i.relative_path = ?",
33
+ (path, path),
34
+ ).fetchall()
35
+ except sqlite3.Error as exc:
36
+ raise StorageError(f"cannot read operation history: {exc}") from exc
37
+ finally:
38
+ connection.close()
39
+ return [str(row["id"]) for row in rows]
40
+
41
+
42
+ def list_history(
43
+ journal: OperationJournal,
44
+ *,
45
+ path: str | None = None,
46
+ actor: str | None = None,
47
+ task_id: str | None = None,
48
+ limit: int = 50,
49
+ ) -> tuple[OperationRecord, ...]:
50
+ """Newest first, filtered by path, actor and task id, in this workspace only."""
51
+
52
+ if limit <= 0:
53
+ return ()
54
+ clauses: list[str] = []
55
+ parameters: list[Any] = []
56
+ if actor is not None:
57
+ clauses.append("actor = ?")
58
+ parameters.append(actor)
59
+ if task_id is not None:
60
+ clauses.append("task_id = ?")
61
+ parameters.append(task_id)
62
+ identifiers = _matching_operations(journal, path) if path is not None else None
63
+ if identifiers is not None:
64
+ if not identifiers:
65
+ return ()
66
+ placeholders = ",".join("?" for _ in identifiers)
67
+ clauses.append(f"id IN ({placeholders})")
68
+ parameters.extend(identifiers)
69
+
70
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
71
+ connection = journal._connect()
72
+ try:
73
+ rows = connection.execute(
74
+ f"SELECT * FROM operations {where} ORDER BY created_at DESC, rowid DESC LIMIT ?",
75
+ (*parameters, limit),
76
+ ).fetchall()
77
+ records: list[OperationRecord] = []
78
+ for row in rows:
79
+ items = connection.execute(
80
+ "SELECT * FROM operation_items WHERE operation_id = ? ORDER BY id",
81
+ (row["id"],),
82
+ ).fetchall()
83
+ records.append(_row_to_operation(row, items))
84
+ except sqlite3.Error as exc:
85
+ raise StorageError(f"cannot read operation history: {exc}") from exc
86
+ finally:
87
+ connection.close()
88
+ return tuple(records)
89
+
90
+
91
+ def _decode(data: bytes) -> str | None:
92
+ try:
93
+ return data.decode("utf-8")
94
+ except UnicodeDecodeError:
95
+ return None
96
+
97
+
98
+ def _read_blob(journal: OperationJournal, digest: str, path: str) -> bytes:
99
+ try:
100
+ return journal.blobs.get_bytes(digest)
101
+ except Exception as exc:
102
+ raise HistoryUnavailableError(
103
+ f"cannot read the recorded content for {path}: {exc}",
104
+ details={"path": path, "hash": digest},
105
+ ) from exc
106
+
107
+
108
+ def _diff_entry(journal: OperationJournal, item: OperationItemRecord) -> DiffEntry:
109
+ before = b""
110
+ after = b""
111
+ if item.before_exists and item.before_blob_hash is not None:
112
+ before = _read_blob(journal, item.before_blob_hash, item.relative_path)
113
+ if item.after_exists and item.after_blob_hash is not None:
114
+ after = _read_blob(journal, item.after_blob_hash, item.relative_path)
115
+ before_text = _decode(before)
116
+ after_text = _decode(after)
117
+ if before_text is None or after_text is None:
118
+ return DiffEntry(
119
+ path=item.relative_path,
120
+ item_type=item.item_type,
121
+ before_exists=item.before_exists,
122
+ after_exists=item.after_exists,
123
+ is_binary=True,
124
+ unified_diff="",
125
+ )
126
+ diff = unified_diff_text(
127
+ before_text,
128
+ after_text,
129
+ fromfile=f"a/{item.relative_path}" if item.before_exists else "/dev/null",
130
+ tofile=f"b/{item.relative_path}" if item.after_exists else "/dev/null",
131
+ )
132
+ return DiffEntry(
133
+ path=item.relative_path,
134
+ item_type=item.item_type,
135
+ before_exists=item.before_exists,
136
+ after_exists=item.after_exists,
137
+ is_binary=False,
138
+ unified_diff=diff,
139
+ )
140
+
141
+
142
+ def diff_operation(journal: OperationJournal, operation_id: str) -> OperationDiff:
143
+ record = get_operation(journal, operation_id)
144
+ return OperationDiff(
145
+ operation_id=record.operation_id,
146
+ items=tuple(
147
+ _diff_entry(journal, item) for item in record.items if item.item_type == "file"
148
+ ),
149
+ )
150
+
151
+
152
+ __all__ = ["diff_operation", "get_operation", "list_history"]