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.
- filekeeper/__init__.py +35 -0
- filekeeper/cli/__init__.py +5 -0
- filekeeper/cli/app.py +866 -0
- filekeeper/cli/exit_codes.py +84 -0
- filekeeper/cli/output.py +244 -0
- filekeeper/core/__init__.py +51 -0
- filekeeper/core/config.py +133 -0
- filekeeper/core/database.py +226 -0
- filekeeper/core/diffing.py +127 -0
- filekeeper/core/errors.py +278 -0
- filekeeper/core/history/__init__.py +20 -0
- filekeeper/core/history/blobs.py +92 -0
- filekeeper/core/history/journal.py +430 -0
- filekeeper/core/history/repository.py +152 -0
- filekeeper/core/history/undo.py +358 -0
- filekeeper/core/models.py +293 -0
- filekeeper/core/operations/__init__.py +9 -0
- filekeeper/core/operations/directory.py +475 -0
- filekeeper/core/operations/edit.py +1239 -0
- filekeeper/core/operations/read.py +370 -0
- filekeeper/core/operations/search.py +198 -0
- filekeeper/core/operations/write.py +763 -0
- filekeeper/core/paths.py +236 -0
- filekeeper/core/safety/__init__.py +5 -0
- filekeeper/core/safety/atomic.py +129 -0
- filekeeper/core/safety/hashing.py +73 -0
- filekeeper/core/safety/sensitive.py +65 -0
- filekeeper/core/safety/textdata.py +142 -0
- filekeeper/core/safety/vcs.py +51 -0
- filekeeper/core/service.py +706 -0
- filekeeper/core/workspace.py +300 -0
- filekeeper_cli-0.1.0.dist-info/METADATA +498 -0
- filekeeper_cli-0.1.0.dist-info/RECORD +36 -0
- filekeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- filekeeper_cli-0.1.0.dist-info/entry_points.txt +3 -0
- filekeeper_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|