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,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"]
|