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,358 @@
1
+ """Safe undo with up-front conflict detection.
2
+
3
+ Undo never blindly overwrites: every affected path must still match one of the
4
+ two states recorded for it — the post-operation state (untouched since the
5
+ change) or the pre-operation state (already back where undo wants it, which is
6
+ what a partially applied multi-file operation leaves behind). A path that
7
+ matches neither was changed by someone else, and the whole undo is refused
8
+ before a single byte is written.
9
+
10
+ The undo is itself recorded as an operation, so the ledger stays truthful.
11
+ Redo is deliberately absent from this release; see ``README.md``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import stat
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from ..errors import (
22
+ HistoryUnavailableError,
23
+ StorageError,
24
+ UndoConflictError,
25
+ storage_failure,
26
+ )
27
+ from ..models import MutationResult, OperationItemRecord, OperationItemResult, OperationRecord
28
+ from ..safety.atomic import atomic_write_bytes, fsync_directory
29
+ from ..safety.hashing import hash_file
30
+ from .journal import COMMITTED, FAILED, UNDONE, OperationJournal, PendingItem
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class _Step:
35
+ item: OperationItemRecord
36
+ action: str # "restore-file", "restore-directory", "remove-file", "remove-directory", "none"
37
+ data: bytes | None = None
38
+
39
+
40
+ def _target_path(journal: OperationJournal, relative_path: str) -> Path:
41
+ candidate = (journal.root / relative_path).resolve()
42
+ root = journal.root.resolve()
43
+ if candidate != root and root not in candidate.parents:
44
+ raise StorageError(
45
+ "refusing to undo a path outside the workspace",
46
+ details={"path": relative_path},
47
+ )
48
+ return candidate
49
+
50
+
51
+ def _current(item: OperationItemRecord, path: Path) -> tuple[bool, str | None]:
52
+ if item.item_type == "directory":
53
+ return path.is_dir(), None
54
+ if not path.is_file():
55
+ return False, None
56
+ try:
57
+ return True, hash_file(path, display_path=item.relative_path)
58
+ except OSError:
59
+ return False, None
60
+
61
+
62
+ def _matches(
63
+ exists: bool, digest: str | None, current_exists: bool, current_hash: str | None
64
+ ) -> bool:
65
+ if exists != current_exists:
66
+ return False
67
+ if not exists:
68
+ return True
69
+ if digest is None:
70
+ return True
71
+ return current_hash == digest
72
+
73
+
74
+ def _load_before(journal: OperationJournal, item: OperationItemRecord) -> bytes:
75
+ if item.before_blob_hash is None:
76
+ raise HistoryUnavailableError(
77
+ f"history was not stored for {item.relative_path}, so it cannot be restored",
78
+ details={"path": item.relative_path, "operation_item": item.relative_path},
79
+ )
80
+ try:
81
+ return journal.blobs.get_bytes(item.before_blob_hash)
82
+ except StorageError as exc:
83
+ raise HistoryUnavailableError(
84
+ f"the recorded content for {item.relative_path} is missing or corrupt: {exc.message}",
85
+ details={"path": item.relative_path, "hash": item.before_blob_hash},
86
+ ) from exc
87
+
88
+
89
+ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
90
+ steps: list[_Step] = []
91
+ conflicts: list[str] = []
92
+ already_before = 0
93
+
94
+ for item in record.items:
95
+ path = _target_path(journal, item.relative_path)
96
+ current_exists, current_hash = _current(item, path)
97
+ matches_before = _matches(
98
+ item.before_exists, item.before_blob_hash, current_exists, current_hash
99
+ )
100
+ matches_after = _matches(
101
+ item.after_exists, item.after_blob_hash, current_exists, current_hash
102
+ )
103
+ if not matches_before and not matches_after:
104
+ conflicts.append(item.relative_path)
105
+ continue
106
+ if matches_before:
107
+ already_before += 1
108
+ continue
109
+
110
+ if not item.before_exists:
111
+ action = "remove-directory" if item.item_type == "directory" else "remove-file"
112
+ steps.append(_Step(item=item, action=action))
113
+ continue
114
+
115
+ if item.item_type == "directory":
116
+ steps.append(_Step(item=item, action="restore-directory"))
117
+ else:
118
+ steps.append(_Step(item=item, action="restore-file", data=_load_before(journal, item)))
119
+
120
+ # A directory may only be removed when every entry inside it is also being
121
+ # removed by this same undo. That keeps "undo of a directory create" from
122
+ # destroying files the user added afterwards, while allowing a directory
123
+ # move (whose children are all recorded) to be reversed.
124
+ removal_paths = {
125
+ journal.root.joinpath(step.item.relative_path).resolve()
126
+ for step in steps
127
+ if step.action in {"remove-file", "remove-directory"}
128
+ }
129
+ for step in steps:
130
+ if step.action != "remove-directory":
131
+ continue
132
+ directory = _target_path(journal, step.item.relative_path)
133
+ try:
134
+ leftovers = [
135
+ entry for entry in directory.iterdir() if entry.resolve() not in removal_paths
136
+ ]
137
+ except OSError:
138
+ leftovers = [directory]
139
+ if leftovers:
140
+ conflicts.append(step.item.relative_path)
141
+
142
+ if conflicts:
143
+ raise UndoConflictError(
144
+ "cannot undo: these paths changed after the operation, so nothing was modified",
145
+ details={
146
+ "operation_id": record.operation_id,
147
+ "conflicting_paths": ", ".join(sorted(conflicts)),
148
+ },
149
+ )
150
+ if record.status == FAILED and already_before == len(record.items):
151
+ raise UndoConflictError(
152
+ "operation did not take effect; there is nothing to undo",
153
+ details={"operation_id": record.operation_id},
154
+ )
155
+ if record.status == UNDONE:
156
+ raise UndoConflictError(
157
+ "operation is already undone", details={"operation_id": record.operation_id}
158
+ )
159
+ if record.status not in {COMMITTED, FAILED}:
160
+ raise UndoConflictError(
161
+ "operation is not committed and cannot be undone",
162
+ details={"operation_id": record.operation_id, "status": record.status},
163
+ )
164
+ return steps
165
+
166
+
167
+ def _apply(journal: OperationJournal, steps: list[_Step]) -> None:
168
+ directories = [step for step in steps if step.action == "restore-directory"]
169
+ for step in sorted(directories, key=lambda entry: entry.item.relative_path.count("/")):
170
+ path = _target_path(journal, step.item.relative_path)
171
+ try:
172
+ path.mkdir(parents=True, exist_ok=True)
173
+ if step.item.before_mode is not None:
174
+ os.chmod(path, stat.S_IMODE(step.item.before_mode))
175
+ except OSError as exc:
176
+ raise storage_failure("restore directory", step.item.relative_path, exc) from exc
177
+
178
+ for step in steps:
179
+ if step.action != "restore-file":
180
+ continue
181
+ path = _target_path(journal, step.item.relative_path)
182
+ try:
183
+ atomic_write_bytes(
184
+ path,
185
+ step.data or b"",
186
+ mode=step.item.before_mode,
187
+ fsync=journal.config.fsync,
188
+ display_path=step.item.relative_path,
189
+ )
190
+ except OSError as exc:
191
+ raise storage_failure("restore file", step.item.relative_path, exc) from exc
192
+
193
+ removable = [step for step in steps if step.action in {"remove-file", "remove-directory"}]
194
+ files = [step for step in removable if step.action == "remove-file"]
195
+ for step in files:
196
+ path = _target_path(journal, step.item.relative_path)
197
+ try:
198
+ path.unlink()
199
+ fsync_directory(path.parent)
200
+ except OSError as exc:
201
+ raise storage_failure(
202
+ "remove file during undo", step.item.relative_path, exc
203
+ ) from exc
204
+
205
+ directories = [step for step in removable if step.action == "remove-directory"]
206
+ for step in sorted(
207
+ directories, key=lambda entry: entry.item.relative_path.count("/"), reverse=True
208
+ ):
209
+ path = _target_path(journal, step.item.relative_path)
210
+ try:
211
+ path.rmdir()
212
+ fsync_directory(path.parent)
213
+ except OSError as exc:
214
+ raise storage_failure(
215
+ "remove directory during undo", step.item.relative_path, exc
216
+ ) from exc
217
+
218
+
219
+ def _undo_items(record: OperationRecord) -> list[PendingItem]:
220
+ items: list[PendingItem] = []
221
+ for item in record.items:
222
+ items.append(
223
+ PendingItem(
224
+ relative_path=item.relative_path,
225
+ item_type=item.item_type,
226
+ before_exists=item.after_exists,
227
+ after_exists=item.before_exists,
228
+ before_hash=item.after_blob_hash,
229
+ after_hash=item.before_blob_hash,
230
+ before_mode=item.after_mode,
231
+ after_mode=item.before_mode,
232
+ )
233
+ )
234
+ return items
235
+
236
+
237
+ def _result(
238
+ journal: OperationJournal,
239
+ record: OperationRecord,
240
+ undo_operation_id: str,
241
+ *,
242
+ workspace_name: str,
243
+ steps: list[_Step],
244
+ ) -> MutationResult:
245
+ items = tuple(
246
+ OperationItemResult(
247
+ path=step.item.relative_path,
248
+ change=(
249
+ "deleted"
250
+ if not step.item.before_exists
251
+ else "created"
252
+ if not step.item.after_exists
253
+ else "modified"
254
+ ),
255
+ before_hash=step.item.after_blob_hash,
256
+ after_hash=step.item.before_blob_hash,
257
+ )
258
+ for step in steps
259
+ if step.action != "none"
260
+ )
261
+ return MutationResult(
262
+ workspace=workspace_name,
263
+ path=record.target_path or "",
264
+ changed=True,
265
+ before_hash=record.after_hash,
266
+ after_hash=record.before_hash,
267
+ summary=f"Undid operation {record.operation_id}.",
268
+ operation_id=undo_operation_id,
269
+ undo_available=True,
270
+ items=items,
271
+ affected_count=len(items),
272
+ )
273
+
274
+
275
+ def undo_operation(
276
+ journal: OperationJournal,
277
+ operation_id: str,
278
+ *,
279
+ workspace_name: str,
280
+ actor: str | None = None,
281
+ task_id: str | None = None,
282
+ ) -> MutationResult:
283
+ """Restore the recorded pre-state of one operation, or refuse and change nothing."""
284
+
285
+ record = journal.get(operation_id)
286
+ if record is None:
287
+ from ..errors import OperationNotFoundError
288
+
289
+ raise OperationNotFoundError(
290
+ "no operation with that id in this workspace", details={"operation_id": operation_id}
291
+ )
292
+ steps = _plan(journal, record)
293
+ undo_items = _undo_items(record)
294
+
295
+ begun = journal.begin(
296
+ operation_type="undo",
297
+ target_path=record.target_path,
298
+ summary=f"Undid operation {record.operation_id}.",
299
+ items=undo_items,
300
+ actor=actor,
301
+ task_id=task_id,
302
+ before_hash=record.after_hash,
303
+ after_hash=record.before_hash,
304
+ metadata={"undone_operation": record.operation_id},
305
+ )
306
+ try:
307
+ _apply(journal, steps)
308
+ except Exception:
309
+ journal.abort(begun.operation_id)
310
+ raise
311
+ journal.commit(begun.operation_id)
312
+ journal._set_status(record.operation_id, UNDONE)
313
+ return _result(
314
+ journal, record, begun.operation_id, workspace_name=workspace_name, steps=steps
315
+ )
316
+
317
+
318
+ def latest_undoable(journal: OperationJournal, *, actor: str | None = None) -> OperationRecord:
319
+ clauses = ["status IN (?, ?)"]
320
+ parameters: list[object] = [COMMITTED, FAILED]
321
+ if actor is not None:
322
+ clauses.append("actor = ?")
323
+ parameters.append(actor)
324
+ connection = journal._connect()
325
+ try:
326
+ row = connection.execute(
327
+ f"SELECT * FROM operations WHERE {' AND '.join(clauses)} "
328
+ "ORDER BY created_at DESC, rowid DESC LIMIT 1",
329
+ tuple(parameters),
330
+ ).fetchone()
331
+ finally:
332
+ connection.close()
333
+ if row is None:
334
+ raise UndoConflictError(
335
+ "no undoable operation in this workspace",
336
+ details={"actor": actor},
337
+ )
338
+ record = journal.get(str(row["id"]))
339
+ assert record is not None
340
+ return record
341
+
342
+
343
+ def undo_last(
344
+ journal: OperationJournal,
345
+ *,
346
+ workspace_name: str,
347
+ actor: str | None = None,
348
+ task_id: str | None = None,
349
+ ) -> MutationResult:
350
+ """Undo the most recent operation in *this* workspace; never another one's."""
351
+
352
+ record = latest_undoable(journal, actor=actor)
353
+ return undo_operation(
354
+ journal, record.operation_id, workspace_name=workspace_name, actor=actor, task_id=task_id
355
+ )
356
+
357
+
358
+ __all__ = ["latest_undoable", "undo_last", "undo_operation"]
@@ -0,0 +1,293 @@
1
+ """Value types returned by Core.
2
+
3
+ All result types are frozen dataclasses. Paths in results are
4
+ workspace-relative POSIX strings so that CLI and MCP output are identical and
5
+ free of host-specific absolute paths.
6
+
7
+ Hashes are SHA-256 and are formatted as ``sha256:<64 hex characters>``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass, field
13
+ from typing import Any, Literal
14
+
15
+ EntryKind = Literal["file", "directory", "symlink", "other"]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Workspace:
20
+ """A registered workspace root."""
21
+
22
+ id: str
23
+ name: str
24
+ root_path: str
25
+ created_at: str
26
+ updated_at: str
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ErrorDetail:
31
+ """An error rendered as data, for multi-result calls that must not raise."""
32
+
33
+ code: str
34
+ message: str
35
+ retryable: bool = False
36
+ details: dict[str, Any] = field(default_factory=dict)
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ payload: dict[str, Any] = {
40
+ "code": self.code,
41
+ "message": self.message,
42
+ "retryable": self.retryable,
43
+ }
44
+ if self.details:
45
+ payload["details"] = self.details
46
+ return payload
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class FileReadResult:
51
+ """A bounded read. ``content`` holds the requested lines with their original endings.
52
+
53
+ Line numbers are 1-based and inclusive. ``total_lines`` is ``None`` when the
54
+ read was truncated byte-wise before the end of the file; ``next_start_line``
55
+ then names the first line that was not returned.
56
+ """
57
+
58
+ path: str
59
+ content: str
60
+ start_line: int
61
+ end_line: int
62
+ returned_line_count: int
63
+ total_lines: int | None
64
+ size_bytes: int
65
+ content_hash: str
66
+ encoding: str
67
+ newline: str
68
+ has_bom: bool
69
+ truncated: bool = False
70
+ truncation_reason: str | None = None
71
+ next_start_line: int | None = None
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ReadManyEntry:
76
+ """One file's outcome inside :meth:`read_multiple_files`; failures are data, not raises."""
77
+
78
+ path: str
79
+ ok: bool
80
+ result: FileReadResult | None = None
81
+ error: ErrorDetail | None = None
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class DirectoryEntry:
86
+ path: str
87
+ name: str
88
+ kind: EntryKind
89
+ size_bytes: int
90
+ is_symlink: bool
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class DirectoryListing:
95
+ path: str
96
+ entries: tuple[DirectoryEntry, ...]
97
+ truncated: bool
98
+ max_entries: int
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class PathInfo:
103
+ path: str
104
+ kind: EntryKind
105
+ size_bytes: int
106
+ mode: int
107
+ modified_at: str
108
+ is_symlink: bool
109
+ symlink_target: str | None = None
110
+ is_text: bool | None = None
111
+ content_hash: str | None = None
112
+ line_count: int | None = None
113
+ encoding: str | None = None
114
+ newline: str | None = None
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class ExistsResult:
119
+ path: str
120
+ exists: bool
121
+ kind: EntryKind | None = None
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class PathSearchResult:
126
+ path: str
127
+ glob: str
128
+ paths: tuple[str, ...]
129
+ truncated: bool
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class ContentMatch:
134
+ path: str
135
+ line_number: int
136
+ line: str
137
+ column: int
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class ContentSearchResult:
142
+ path: str
143
+ query: str
144
+ regex: bool
145
+ matches: tuple[ContentMatch, ...]
146
+ truncated: bool
147
+ files_searched: int
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class OperationItemResult:
152
+ """One path touched by a mutation.
153
+
154
+ ``operation_item_id`` is reserved for the history layer; it is ``None`` in
155
+ releases that do not record history.
156
+ """
157
+
158
+ path: str
159
+ change: Literal["created", "modified", "deleted", "moved", "unchanged"]
160
+ before_hash: str | None = None
161
+ after_hash: str | None = None
162
+ size_bytes: int | None = None
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class OperationItemRecord:
167
+ """One path recorded against an operation in the durable history."""
168
+
169
+ relative_path: str
170
+ item_type: str
171
+ before_blob_hash: str | None
172
+ after_blob_hash: str | None
173
+ before_exists: bool
174
+ after_exists: bool
175
+ before_mode: int | None
176
+ after_mode: int | None
177
+
178
+
179
+ @dataclass(frozen=True, slots=True)
180
+ class OperationRecord:
181
+ """An operation as stored in the history ledger.
182
+
183
+ ``operation_id`` is the single key used by every surface: mutations,
184
+ ``history list`` / ``history show``, diff, undo and the MCP tools.
185
+ """
186
+
187
+ operation_id: str
188
+ workspace_id: str
189
+ operation_type: str
190
+ target_path: str | None
191
+ status: str
192
+ created_at: str
193
+ actor: str | None
194
+ task_id: str | None
195
+ summary: str | None
196
+ before_hash: str | None
197
+ after_hash: str | None
198
+ items: tuple[OperationItemRecord, ...] = ()
199
+ history_skipped: tuple[str, ...] = ()
200
+ metadata: dict[str, Any] = field(default_factory=dict)
201
+
202
+
203
+ @dataclass(frozen=True, slots=True)
204
+ class DiffEntry:
205
+ """One path's before/after comparison inside :func:`diff_operation`."""
206
+
207
+ path: str
208
+ item_type: str
209
+ before_exists: bool
210
+ after_exists: bool
211
+ is_binary: bool
212
+ unified_diff: str
213
+
214
+
215
+ @dataclass(frozen=True, slots=True)
216
+ class OperationDiff:
217
+ operation_id: str
218
+ items: tuple[DiffEntry, ...]
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class PatchHunkResult:
223
+ """One unified-diff hunk and what happened to it.
224
+
225
+ ``status`` is ``"applied"`` for a real application, ``"would_apply"`` for a
226
+ dry run, and ``"rejected"`` when a hunk could not be placed. Line numbers in
227
+ a hunk header are 1-based; an empty old side uses position 0.
228
+ """
229
+
230
+ old_start: int
231
+ old_count: int
232
+ new_start: int
233
+ new_count: int
234
+ status: str
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class PatchFileResult:
239
+ """One path affected by :func:`apply_patch`, with its per-hunk outcomes."""
240
+
241
+ path: str
242
+ change: Literal["created", "modified", "deleted", "unchanged"]
243
+ applied: bool
244
+ before_hash: str | None
245
+ after_hash: str | None
246
+ hunks: tuple[PatchHunkResult, ...] = ()
247
+
248
+
249
+ @dataclass(frozen=True, slots=True)
250
+ class MutationResult:
251
+ """The stable shape of every mutating call.
252
+
253
+ ``operation_id`` names the durable history record for the mutation and is
254
+ ``None`` only for a dry run. ``undo_available`` says whether that record can
255
+ restore the previous bytes: it is ``False`` when an affected file exceeded
256
+ the history size cap, because its previous bytes were deliberately not
257
+ stored.
258
+
259
+ ``match_stage`` and ``match_count`` describe text replacement, and
260
+ ``preview_diff`` carries a dry-run unified diff. ``patch_files`` describes a
261
+ patch application; a recovered failure reports what was rolled back in the
262
+ error details.
263
+ """
264
+
265
+ workspace: str
266
+ path: str
267
+ changed: bool
268
+ before_hash: str | None
269
+ after_hash: str | None
270
+ summary: str
271
+ operation_id: str | None = None
272
+ undo_available: bool = False
273
+ dry_run: bool = False
274
+ warnings: tuple[str, ...] = ()
275
+ items: tuple[OperationItemResult, ...] = ()
276
+ affected_count: int | None = None
277
+ history_skipped: tuple[str, ...] = ()
278
+ match_stage: str | None = None
279
+ match_count: int | None = None
280
+ preview_diff: str | None = None
281
+ patch_files: tuple[PatchFileResult, ...] = ()
282
+
283
+ def to_dict(self) -> dict[str, Any]:
284
+ payload: dict[str, Any] = _jsonable(asdict(self))
285
+ return payload
286
+
287
+
288
+ def _jsonable(value: Any) -> Any:
289
+ if isinstance(value, dict):
290
+ return {key: _jsonable(item) for key, item in value.items()}
291
+ if isinstance(value, (list, tuple)):
292
+ return [_jsonable(item) for item in value]
293
+ return value
@@ -0,0 +1,9 @@
1
+ """Filesystem operations, grouped by concern.
2
+
3
+ ``edit`` is a published stub for a later release; importing it is safe and every
4
+ call raises rather than pretending to work.
5
+ """
6
+
7
+ from . import directory, edit, read, search, write
8
+
9
+ __all__ = ["directory", "edit", "read", "search", "write"]