filekeeper-cli 0.1.4__tar.gz → 0.1.6__tar.gz
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_cli-0.1.4 → filekeeper_cli-0.1.6}/PKG-INFO +43 -7
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/README.md +42 -6
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/__init__.py +5 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/output.py +13 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/__init__.py +3 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/errors.py +65 -2
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/journal.py +4 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/undo.py +90 -6
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/models.py +77 -3
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/directory.py +42 -9
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/edit.py +47 -19
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/write.py +87 -14
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/service.py +15 -3
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/LICENSE +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/app.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/config.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/database.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/blobs.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/repository.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/read.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/search.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/paths.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/textdata.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/vcs.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: filekeeper-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement.
|
|
5
5
|
Keywords: filesystem,atomic-write,cli,agents,mcp
|
|
6
6
|
Author: sangingeum
|
|
@@ -244,6 +244,13 @@ later file fails after earlier ones were written, the written files are rolled
|
|
|
244
244
|
back to their pre-patch bytes and the failure names what was restored. A crash is
|
|
245
245
|
resolved from the pending journal record, never from an all-or-nothing commit.
|
|
246
246
|
|
|
247
|
+
When a hunk's header and its body disagree — the hunk is cut short or a line
|
|
248
|
+
prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
|
|
249
|
+
header declares and the counts the body actually supplied, in the message and in
|
|
250
|
+
`details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
|
|
251
|
+
`actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
|
|
252
|
+
text is never echoed into the error.
|
|
253
|
+
|
|
247
254
|
## Commands
|
|
248
255
|
|
|
249
256
|
```
|
|
@@ -304,10 +311,11 @@ affected path with the state the operation recorded:
|
|
|
304
311
|
|
|
305
312
|
- if the path still matches the recorded **after** state, undo restores the
|
|
306
313
|
recorded **before** bytes;
|
|
307
|
-
- if it already matches the **before** state
|
|
308
|
-
operation), undo leaves it alone;
|
|
309
|
-
- if
|
|
310
|
-
`UNDO_CONFLICT` (exit code
|
|
314
|
+
- if it already matches the **before** state of an operation that never committed
|
|
315
|
+
(a partially applied multi-file operation), undo leaves it alone;
|
|
316
|
+
- if a committed operation's path no longer matches the **after** state — edited,
|
|
317
|
+
deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
|
|
318
|
+
12) and **nothing is modified**.
|
|
311
319
|
|
|
312
320
|
Undoing a create removes the file only while it still matches what was created.
|
|
313
321
|
Undoing a delete restores the file only while the destination is still absent.
|
|
@@ -318,6 +326,15 @@ The undo is itself recorded as an operation, so `history list` remains a
|
|
|
318
326
|
truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
|
|
319
327
|
undo is the supported way to go forward again.
|
|
320
328
|
|
|
329
|
+
A copy or a move records the path it read from in the operation's `metadata`
|
|
330
|
+
under `source_path`, and its summary names both ends. `target_path` keeps its
|
|
331
|
+
meaning — the path the operation targets — so existing readers are unaffected.
|
|
332
|
+
|
|
333
|
+
A refused undo is recorded too: the ledger gains an `undo_refused` row with the
|
|
334
|
+
target path, the conflict code, and the paths that blocked the undo. It changed
|
|
335
|
+
nothing, so it carries no items, is never offered as undoable, and `undo-last`
|
|
336
|
+
skips it. Every recorded path is workspace-relative, never a resolved host path.
|
|
337
|
+
|
|
321
338
|
`keeper file read` writes file content to standard output unchanged (line endings
|
|
322
339
|
included). Metadata goes to stderr, or into the JSON object with `--json`.
|
|
323
340
|
`--input FILE` reads replacement content from a file, avoiding shell argument
|
|
@@ -380,6 +397,16 @@ re-reads the file and writes against its new hash.
|
|
|
380
397
|
so its previous bytes were deliberately not stored; `history_skipped` then names
|
|
381
398
|
the paths.
|
|
382
399
|
|
|
400
|
+
**A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
|
|
401
|
+
`false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
|
|
402
|
+
and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
|
|
403
|
+
a real run would produce appears as `predicted_after_hash` — a prediction, never an
|
|
404
|
+
observation — and `would_change` says whether a real run would alter anything.
|
|
405
|
+
`before_hash` and `preview_diff` are kept, because they describe the state the
|
|
406
|
+
preview was computed against. The same shape applies to every mutating command and
|
|
407
|
+
to the MCP tools that mirror them. A committed operation still reports
|
|
408
|
+
`"changed": true` with its observed `after_hash`.
|
|
409
|
+
|
|
383
410
|
Editing results add `match_stage` and `match_count` (text replacement),
|
|
384
411
|
`preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
|
|
385
412
|
A failure that was rolled back reports the restored paths in the error details.
|
|
@@ -421,6 +448,13 @@ a shell predicate.
|
|
|
421
448
|
|
|
422
449
|
Codes are stable; messages are not a contract.
|
|
423
450
|
|
|
451
|
+
Messages name the interface the caller is using. A refusal that offers an
|
|
452
|
+
override states that interface's own argument: Core's keyword, the CLI's flag, or
|
|
453
|
+
the MCP tool argument. The same request therefore reads correctly whether it came
|
|
454
|
+
from a library, a shell, or an agent over MCP, and an adapter never prints another
|
|
455
|
+
surface's argument. Codes, `retryable` and `details` are identical across all
|
|
456
|
+
three surfaces.
|
|
457
|
+
|
|
424
458
|
## Safety rules
|
|
425
459
|
|
|
426
460
|
- **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
|
|
@@ -435,8 +469,10 @@ Codes are stable; messages are not a contract.
|
|
|
435
469
|
`*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
|
|
436
470
|
`.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
|
|
437
471
|
`SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
|
|
438
|
-
`--i-accept-secret-write-risk`
|
|
439
|
-
|
|
472
|
+
`--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
|
|
473
|
+
or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
|
|
474
|
+
mistake is expensive. The error message names only the argument belonging to the
|
|
475
|
+
interface that received the request.
|
|
440
476
|
- **Version-control-ignored targets warn.** Writing a path that git ignores still
|
|
441
477
|
succeeds, but the result carries a warning that the change will not be committed.
|
|
442
478
|
- **No shell.** Filekeeper never interpolates paths into a shell command.
|
|
@@ -223,6 +223,13 @@ later file fails after earlier ones were written, the written files are rolled
|
|
|
223
223
|
back to their pre-patch bytes and the failure names what was restored. A crash is
|
|
224
224
|
resolved from the pending journal record, never from an all-or-nothing commit.
|
|
225
225
|
|
|
226
|
+
When a hunk's header and its body disagree — the hunk is cut short or a line
|
|
227
|
+
prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
|
|
228
|
+
header declares and the counts the body actually supplied, in the message and in
|
|
229
|
+
`details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
|
|
230
|
+
`actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
|
|
231
|
+
text is never echoed into the error.
|
|
232
|
+
|
|
226
233
|
## Commands
|
|
227
234
|
|
|
228
235
|
```
|
|
@@ -283,10 +290,11 @@ affected path with the state the operation recorded:
|
|
|
283
290
|
|
|
284
291
|
- if the path still matches the recorded **after** state, undo restores the
|
|
285
292
|
recorded **before** bytes;
|
|
286
|
-
- if it already matches the **before** state
|
|
287
|
-
operation), undo leaves it alone;
|
|
288
|
-
- if
|
|
289
|
-
`UNDO_CONFLICT` (exit code
|
|
293
|
+
- if it already matches the **before** state of an operation that never committed
|
|
294
|
+
(a partially applied multi-file operation), undo leaves it alone;
|
|
295
|
+
- if a committed operation's path no longer matches the **after** state — edited,
|
|
296
|
+
deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
|
|
297
|
+
12) and **nothing is modified**.
|
|
290
298
|
|
|
291
299
|
Undoing a create removes the file only while it still matches what was created.
|
|
292
300
|
Undoing a delete restores the file only while the destination is still absent.
|
|
@@ -297,6 +305,15 @@ The undo is itself recorded as an operation, so `history list` remains a
|
|
|
297
305
|
truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
|
|
298
306
|
undo is the supported way to go forward again.
|
|
299
307
|
|
|
308
|
+
A copy or a move records the path it read from in the operation's `metadata`
|
|
309
|
+
under `source_path`, and its summary names both ends. `target_path` keeps its
|
|
310
|
+
meaning — the path the operation targets — so existing readers are unaffected.
|
|
311
|
+
|
|
312
|
+
A refused undo is recorded too: the ledger gains an `undo_refused` row with the
|
|
313
|
+
target path, the conflict code, and the paths that blocked the undo. It changed
|
|
314
|
+
nothing, so it carries no items, is never offered as undoable, and `undo-last`
|
|
315
|
+
skips it. Every recorded path is workspace-relative, never a resolved host path.
|
|
316
|
+
|
|
300
317
|
`keeper file read` writes file content to standard output unchanged (line endings
|
|
301
318
|
included). Metadata goes to stderr, or into the JSON object with `--json`.
|
|
302
319
|
`--input FILE` reads replacement content from a file, avoiding shell argument
|
|
@@ -359,6 +376,16 @@ re-reads the file and writes against its new hash.
|
|
|
359
376
|
so its previous bytes were deliberately not stored; `history_skipped` then names
|
|
360
377
|
the paths.
|
|
361
378
|
|
|
379
|
+
**A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
|
|
380
|
+
`false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
|
|
381
|
+
and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
|
|
382
|
+
a real run would produce appears as `predicted_after_hash` — a prediction, never an
|
|
383
|
+
observation — and `would_change` says whether a real run would alter anything.
|
|
384
|
+
`before_hash` and `preview_diff` are kept, because they describe the state the
|
|
385
|
+
preview was computed against. The same shape applies to every mutating command and
|
|
386
|
+
to the MCP tools that mirror them. A committed operation still reports
|
|
387
|
+
`"changed": true` with its observed `after_hash`.
|
|
388
|
+
|
|
362
389
|
Editing results add `match_stage` and `match_count` (text replacement),
|
|
363
390
|
`preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
|
|
364
391
|
A failure that was rolled back reports the restored paths in the error details.
|
|
@@ -400,6 +427,13 @@ a shell predicate.
|
|
|
400
427
|
|
|
401
428
|
Codes are stable; messages are not a contract.
|
|
402
429
|
|
|
430
|
+
Messages name the interface the caller is using. A refusal that offers an
|
|
431
|
+
override states that interface's own argument: Core's keyword, the CLI's flag, or
|
|
432
|
+
the MCP tool argument. The same request therefore reads correctly whether it came
|
|
433
|
+
from a library, a shell, or an agent over MCP, and an adapter never prints another
|
|
434
|
+
surface's argument. Codes, `retryable` and `details` are identical across all
|
|
435
|
+
three surfaces.
|
|
436
|
+
|
|
403
437
|
## Safety rules
|
|
404
438
|
|
|
405
439
|
- **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
|
|
@@ -414,8 +448,10 @@ Codes are stable; messages are not a contract.
|
|
|
414
448
|
`*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
|
|
415
449
|
`.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
|
|
416
450
|
`SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
|
|
417
|
-
`--i-accept-secret-write-risk`
|
|
418
|
-
|
|
451
|
+
`--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
|
|
452
|
+
or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
|
|
453
|
+
mistake is expensive. The error message names only the argument belonging to the
|
|
454
|
+
interface that received the request.
|
|
419
455
|
- **Version-control-ignored targets warn.** Writing a path that git ignores still
|
|
420
456
|
succeeds, but the result carries a warning that the change will not be committed.
|
|
421
457
|
- **No shell.** Filekeeper never interpolates paths into a shell command.
|
|
@@ -12,24 +12,28 @@ work; see ``CHANGELOG.md`` and ``README.md``.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from .core import (
|
|
15
|
+
CallerSurface,
|
|
15
16
|
Config,
|
|
16
17
|
ErrorCode,
|
|
17
18
|
Filekeeper,
|
|
18
19
|
FilekeeperError,
|
|
19
20
|
FilekeeperService,
|
|
20
21
|
MutationResult,
|
|
22
|
+
OverrideHint,
|
|
21
23
|
Workspace,
|
|
22
24
|
)
|
|
23
25
|
|
|
24
|
-
__version__ = "0.1.
|
|
26
|
+
__version__ = "0.1.6"
|
|
25
27
|
|
|
26
28
|
__all__ = [
|
|
29
|
+
"CallerSurface",
|
|
27
30
|
"Config",
|
|
28
31
|
"ErrorCode",
|
|
29
32
|
"Filekeeper",
|
|
30
33
|
"FilekeeperError",
|
|
31
34
|
"FilekeeperService",
|
|
32
35
|
"MutationResult",
|
|
36
|
+
"OverrideHint",
|
|
33
37
|
"Workspace",
|
|
34
38
|
"__version__",
|
|
35
39
|
]
|
|
@@ -14,7 +14,7 @@ from enum import Enum
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
from ..core.errors import FilekeeperError
|
|
17
|
+
from ..core.errors import CallerSurface, FilekeeperError
|
|
18
18
|
from ..core.models import (
|
|
19
19
|
ContentSearchResult,
|
|
20
20
|
DirectoryListing,
|
|
@@ -53,6 +53,9 @@ def print_json(payload: dict[str, Any]) -> None:
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def print_error(error: FilekeeperError, *, as_json: bool) -> None:
|
|
56
|
+
"""Render an error for this surface: its override hint names this CLI's flag."""
|
|
57
|
+
|
|
58
|
+
error = error.for_surface(CallerSurface.CLI)
|
|
56
59
|
if as_json:
|
|
57
60
|
print_json({"ok": False, "error": error.to_dict()})
|
|
58
61
|
return
|
|
@@ -210,6 +213,15 @@ def history_show(record: OperationRecord) -> str:
|
|
|
210
213
|
f" {item.item_type:<9} {item.relative_path} exists {exists} "
|
|
211
214
|
f"before {item.before_blob_hash or '-'} after {item.after_blob_hash or '-'}"
|
|
212
215
|
)
|
|
216
|
+
if record.metadata:
|
|
217
|
+
lines.append("metadata:")
|
|
218
|
+
for key in sorted(record.metadata):
|
|
219
|
+
value = record.metadata[key]
|
|
220
|
+
if isinstance(value, list):
|
|
221
|
+
rendered = ", ".join(str(entry) for entry in value)
|
|
222
|
+
else:
|
|
223
|
+
rendered = str(value)
|
|
224
|
+
lines.append(f" {key}: {rendered}")
|
|
213
225
|
if record.history_skipped:
|
|
214
226
|
lines.append("history_skipped: " + ", ".join(record.history_skipped))
|
|
215
227
|
return "\n".join(lines)
|
|
@@ -5,7 +5,7 @@ the CLI, of the MCP server, or of any filesystem protocol.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from .config import Config
|
|
8
|
-
from .errors import ErrorCode, FilekeeperError
|
|
8
|
+
from .errors import CallerSurface, ErrorCode, FilekeeperError, OverrideHint
|
|
9
9
|
from .history.blobs import BlobStore
|
|
10
10
|
from .models import (
|
|
11
11
|
ContentMatch,
|
|
@@ -27,6 +27,7 @@ from .workspace import WorkspaceHandle, WorkspaceRegistry
|
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
29
|
"BlobStore",
|
|
30
|
+
"CallerSurface",
|
|
30
31
|
"Config",
|
|
31
32
|
"ContentMatch",
|
|
32
33
|
"ContentSearchResult",
|
|
@@ -41,6 +42,7 @@ __all__ = [
|
|
|
41
42
|
"FilekeeperService",
|
|
42
43
|
"MutationResult",
|
|
43
44
|
"OperationItemResult",
|
|
45
|
+
"OverrideHint",
|
|
44
46
|
"PathInfo",
|
|
45
47
|
"PathSearchResult",
|
|
46
48
|
"ReadManyEntry",
|
|
@@ -7,6 +7,9 @@ error payloads, and callers may branch on the code without parsing prose.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import copy
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
10
13
|
from enum import StrEnum
|
|
11
14
|
from typing import Any
|
|
12
15
|
|
|
@@ -46,6 +49,38 @@ class ErrorCode(StrEnum):
|
|
|
46
49
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
class CallerSurface(StrEnum):
|
|
53
|
+
"""The interface a caller reached Core through.
|
|
54
|
+
|
|
55
|
+
A refusal that offers an override must name the argument the caller actually
|
|
56
|
+
has. Core renders the sentence for ``CORE``; each adapter re-renders the same
|
|
57
|
+
error for its own surface before returning it, so no message ever names
|
|
58
|
+
another interface's argument.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
CORE = "core"
|
|
62
|
+
CLI = "cli"
|
|
63
|
+
MCP = "mcp"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class OverrideHint:
|
|
68
|
+
"""One "pass X to override" instruction, worded for each caller surface."""
|
|
69
|
+
|
|
70
|
+
sentences: Mapping[CallerSurface, str]
|
|
71
|
+
|
|
72
|
+
def sentence(self, surface: CallerSurface) -> str:
|
|
73
|
+
return self.sentences[surface]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def override_hint(*, core: str, cli: str, mcp: str) -> OverrideHint:
|
|
77
|
+
"""Build a hint whose sentence names each surface's own argument."""
|
|
78
|
+
|
|
79
|
+
return OverrideHint(
|
|
80
|
+
{CallerSurface.CORE: core, CallerSurface.CLI: cli, CallerSurface.MCP: mcp}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
49
84
|
class FilekeeperError(Exception):
|
|
50
85
|
"""Base error carrying a stable code, a human message, and optional details."""
|
|
51
86
|
|
|
@@ -59,6 +94,7 @@ class FilekeeperError(Exception):
|
|
|
59
94
|
code: ErrorCode | None = None,
|
|
60
95
|
retryable: bool | None = None,
|
|
61
96
|
details: dict[str, Any] | None = None,
|
|
97
|
+
override_hint: OverrideHint | None = None,
|
|
62
98
|
) -> None:
|
|
63
99
|
super().__init__(message)
|
|
64
100
|
self.message = message
|
|
@@ -67,6 +103,27 @@ class FilekeeperError(Exception):
|
|
|
67
103
|
if retryable is not None:
|
|
68
104
|
self.retryable = retryable
|
|
69
105
|
self.details: dict[str, Any] = details or {}
|
|
106
|
+
# Not serialised: the hint only lets an adapter re-word the message.
|
|
107
|
+
self.override_hint = override_hint
|
|
108
|
+
|
|
109
|
+
def for_surface(self, surface: CallerSurface) -> FilekeeperError:
|
|
110
|
+
"""Return this error worded for the interface that received it.
|
|
111
|
+
|
|
112
|
+
``code``, ``retryable`` and ``details`` are carried over untouched; only
|
|
113
|
+
the message's override sentence changes. An error with no hint, or a
|
|
114
|
+
request for the surface Core already rendered, is returned as is.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
hint = self.override_hint
|
|
118
|
+
if hint is None or surface is CallerSurface.CORE:
|
|
119
|
+
return self
|
|
120
|
+
core_sentence = hint.sentence(CallerSurface.CORE)
|
|
121
|
+
if core_sentence not in self.message:
|
|
122
|
+
return self
|
|
123
|
+
adapted = copy.copy(self)
|
|
124
|
+
adapted.message = self.message.replace(core_sentence, hint.sentence(surface))
|
|
125
|
+
adapted.args = (adapted.message,)
|
|
126
|
+
return adapted
|
|
70
127
|
|
|
71
128
|
def to_dict(self) -> dict[str, Any]:
|
|
72
129
|
payload: dict[str, Any] = {
|
|
@@ -135,8 +192,14 @@ class SensitivePathRefusedError(FilekeeperError):
|
|
|
135
192
|
|
|
136
193
|
code = ErrorCode.SENSITIVE_PATH_REFUSED
|
|
137
194
|
|
|
138
|
-
def __init__(
|
|
139
|
-
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
message: str,
|
|
198
|
+
*,
|
|
199
|
+
details: dict[str, Any] | None = None,
|
|
200
|
+
override_hint: OverrideHint | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
super().__init__(message, details=details, override_hint=override_hint)
|
|
140
203
|
|
|
141
204
|
|
|
142
205
|
class AlreadyExistsError(FilekeeperError):
|
|
@@ -38,6 +38,9 @@ COMMITTED = "committed"
|
|
|
38
38
|
UNDONE = "undone"
|
|
39
39
|
FAILED = "failed"
|
|
40
40
|
PARTIALLY_UNDONE = "partially_undone"
|
|
41
|
+
# A refused undo is recorded so the ledger shows the attempt, but it is not a
|
|
42
|
+
# mutation and is never offered as undoable.
|
|
43
|
+
UNDO_REFUSED = "undo_refused"
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
@dataclass(frozen=True, slots=True)
|
|
@@ -424,6 +427,7 @@ __all__ = [
|
|
|
424
427
|
"PARTIALLY_UNDONE",
|
|
425
428
|
"PENDING",
|
|
426
429
|
"UNDONE",
|
|
430
|
+
"UNDO_REFUSED",
|
|
427
431
|
"BeginResult",
|
|
428
432
|
"OperationJournal",
|
|
429
433
|
"PendingItem",
|
|
@@ -19,6 +19,7 @@ from dataclasses import dataclass
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
from ..errors import (
|
|
22
|
+
FilekeeperError,
|
|
22
23
|
HistoryUnavailableError,
|
|
23
24
|
StorageError,
|
|
24
25
|
UndoConflictError,
|
|
@@ -27,7 +28,14 @@ from ..errors import (
|
|
|
27
28
|
from ..models import MutationResult, OperationItemRecord, OperationItemResult, OperationRecord
|
|
28
29
|
from ..safety.atomic import atomic_write_bytes, fsync_directory
|
|
29
30
|
from ..safety.hashing import hash_file
|
|
30
|
-
from .journal import
|
|
31
|
+
from .journal import (
|
|
32
|
+
COMMITTED,
|
|
33
|
+
FAILED,
|
|
34
|
+
UNDO_REFUSED,
|
|
35
|
+
UNDONE,
|
|
36
|
+
OperationJournal,
|
|
37
|
+
PendingItem,
|
|
38
|
+
)
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
@dataclass(frozen=True, slots=True)
|
|
@@ -104,6 +112,13 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
|
|
|
104
112
|
conflicts.append(item.relative_path)
|
|
105
113
|
continue
|
|
106
114
|
if matches_before:
|
|
115
|
+
# A committed operation left every item in its post-operation state.
|
|
116
|
+
# Matching only the pre-operation state now means the path was
|
|
117
|
+
# changed or removed by someone else, not that this undo already
|
|
118
|
+
# ran. Only a failed operation can legitimately be part-way back.
|
|
119
|
+
if record.status == COMMITTED and not matches_after:
|
|
120
|
+
conflicts.append(item.relative_path)
|
|
121
|
+
continue
|
|
107
122
|
already_before += 1
|
|
108
123
|
continue
|
|
109
124
|
|
|
@@ -140,11 +155,13 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
|
|
|
140
155
|
conflicts.append(step.item.relative_path)
|
|
141
156
|
|
|
142
157
|
if conflicts:
|
|
158
|
+
named = ", ".join(sorted(conflicts))
|
|
143
159
|
raise UndoConflictError(
|
|
144
|
-
"cannot undo: these paths changed after the operation,
|
|
160
|
+
f"cannot undo: these paths changed after the operation ({named}), "
|
|
161
|
+
"so nothing was modified",
|
|
145
162
|
details={
|
|
146
163
|
"operation_id": record.operation_id,
|
|
147
|
-
"conflicting_paths":
|
|
164
|
+
"conflicting_paths": named,
|
|
148
165
|
},
|
|
149
166
|
)
|
|
150
167
|
if record.status == FAILED and already_before == len(record.items):
|
|
@@ -161,6 +178,16 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
|
|
|
161
178
|
"operation is not committed and cannot be undone",
|
|
162
179
|
details={"operation_id": record.operation_id, "status": record.status},
|
|
163
180
|
)
|
|
181
|
+
if not steps:
|
|
182
|
+
# Every item already holds its pre-operation state, which for a committed
|
|
183
|
+
# operation means the write left the content unchanged. There is nothing
|
|
184
|
+
# to restore, and reporting success would claim a change that never
|
|
185
|
+
# happened.
|
|
186
|
+
raise UndoConflictError(
|
|
187
|
+
"cannot undo: the operation left its target in the pre-operation state, "
|
|
188
|
+
"so undoing it would change nothing",
|
|
189
|
+
details={"operation_id": record.operation_id},
|
|
190
|
+
)
|
|
164
191
|
return steps
|
|
165
192
|
|
|
166
193
|
|
|
@@ -272,6 +299,53 @@ def _result(
|
|
|
272
299
|
)
|
|
273
300
|
|
|
274
301
|
|
|
302
|
+
def _conflicting_paths(error: UndoConflictError) -> list[str]:
|
|
303
|
+
"""The refusal's paths as a list, still caller-relative as Core recorded them."""
|
|
304
|
+
|
|
305
|
+
named = error.details.get("conflicting_paths")
|
|
306
|
+
if not isinstance(named, str):
|
|
307
|
+
return []
|
|
308
|
+
return sorted({part.strip() for part in named.split(",") if part.strip()})
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _record_refusal(
|
|
312
|
+
journal: OperationJournal,
|
|
313
|
+
record: OperationRecord,
|
|
314
|
+
error: UndoConflictError,
|
|
315
|
+
*,
|
|
316
|
+
actor: str | None,
|
|
317
|
+
task_id: str | None,
|
|
318
|
+
) -> str | None:
|
|
319
|
+
"""Record a refused undo attempt so the ledger shows it was tried.
|
|
320
|
+
|
|
321
|
+
A refusal changes nothing, so the row carries no items and no before/after
|
|
322
|
+
hashes. Its status is ``failed`` — the attempt did not take effect — and its
|
|
323
|
+
type keeps it out of the undo stack (:func:`latest_undoable`). The conflict
|
|
324
|
+
code and the paths that blocked the undo travel in metadata. Recording is
|
|
325
|
+
best-effort: a storage failure here must never replace the refusal the
|
|
326
|
+
caller is owed.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
begun = journal.begin(
|
|
331
|
+
operation_type=UNDO_REFUSED,
|
|
332
|
+
target_path=record.target_path,
|
|
333
|
+
summary=f"Refused to undo operation {record.operation_id}: {error.message}",
|
|
334
|
+
items=[],
|
|
335
|
+
actor=actor,
|
|
336
|
+
task_id=task_id,
|
|
337
|
+
metadata={
|
|
338
|
+
"undone_operation": record.operation_id,
|
|
339
|
+
"conflict_code": str(error.code),
|
|
340
|
+
"conflicting_paths": _conflicting_paths(error),
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
journal.abort(begun.operation_id)
|
|
344
|
+
except FilekeeperError:
|
|
345
|
+
return None
|
|
346
|
+
return begun.operation_id
|
|
347
|
+
|
|
348
|
+
|
|
275
349
|
def undo_operation(
|
|
276
350
|
journal: OperationJournal,
|
|
277
351
|
operation_id: str,
|
|
@@ -289,7 +363,11 @@ def undo_operation(
|
|
|
289
363
|
raise OperationNotFoundError(
|
|
290
364
|
"no operation with that id in this workspace", details={"operation_id": operation_id}
|
|
291
365
|
)
|
|
292
|
-
|
|
366
|
+
try:
|
|
367
|
+
steps = _plan(journal, record)
|
|
368
|
+
except UndoConflictError as exc:
|
|
369
|
+
_record_refusal(journal, record, exc, actor=actor, task_id=task_id)
|
|
370
|
+
raise
|
|
293
371
|
undo_items = _undo_items(record)
|
|
294
372
|
|
|
295
373
|
begun = journal.begin(
|
|
@@ -316,8 +394,14 @@ def undo_operation(
|
|
|
316
394
|
|
|
317
395
|
|
|
318
396
|
def latest_undoable(journal: OperationJournal, *, actor: str | None = None) -> OperationRecord:
|
|
319
|
-
|
|
320
|
-
|
|
397
|
+
"""The most recent operation this workspace may undo.
|
|
398
|
+
|
|
399
|
+
A recorded refusal changed nothing; it is not a mutation and is never
|
|
400
|
+
offered as the next undo target, so ``undo-last`` skips those rows.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
clauses = ["status IN (?, ?)", "operation_type != ?"]
|
|
404
|
+
parameters: list[object] = [COMMITTED, FAILED, UNDO_REFUSED]
|
|
321
405
|
if actor is not None:
|
|
322
406
|
clauses.append("actor = ?")
|
|
323
407
|
parameters.append(actor)
|
|
@@ -9,7 +9,7 @@ Hashes are SHA-256 and are formatted as ``sha256:<64 hex characters>``.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
13
13
|
from typing import Any, Literal
|
|
14
14
|
|
|
15
15
|
EntryKind = Literal["file", "directory", "symlink", "other"]
|
|
@@ -153,6 +153,10 @@ class OperationItemResult:
|
|
|
153
153
|
|
|
154
154
|
``operation_item_id`` is reserved for the history layer; it is ``None`` in
|
|
155
155
|
releases that do not record history.
|
|
156
|
+
|
|
157
|
+
``after_hash`` and ``size_bytes`` describe observed state: on a dry run they
|
|
158
|
+
are ``None`` because nothing was written. A dry run puts the predicted digest
|
|
159
|
+
in ``predicted_after_hash`` instead.
|
|
156
160
|
"""
|
|
157
161
|
|
|
158
162
|
path: str
|
|
@@ -160,6 +164,7 @@ class OperationItemResult:
|
|
|
160
164
|
before_hash: str | None = None
|
|
161
165
|
after_hash: str | None = None
|
|
162
166
|
size_bytes: int | None = None
|
|
167
|
+
predicted_after_hash: str | None = None
|
|
163
168
|
|
|
164
169
|
|
|
165
170
|
@dataclass(frozen=True, slots=True)
|
|
@@ -236,7 +241,11 @@ class PatchHunkResult:
|
|
|
236
241
|
|
|
237
242
|
@dataclass(frozen=True, slots=True)
|
|
238
243
|
class PatchFileResult:
|
|
239
|
-
"""One path affected by :func:`apply_patch`, with its per-hunk outcomes.
|
|
244
|
+
"""One path affected by :func:`apply_patch`, with its per-hunk outcomes.
|
|
245
|
+
|
|
246
|
+
``applied`` is ``False`` for a dry run, and ``after_hash`` is then ``None``;
|
|
247
|
+
the predicted digest is carried by ``predicted_after_hash``.
|
|
248
|
+
"""
|
|
240
249
|
|
|
241
250
|
path: str
|
|
242
251
|
change: Literal["created", "modified", "deleted", "unchanged"]
|
|
@@ -244,6 +253,7 @@ class PatchFileResult:
|
|
|
244
253
|
before_hash: str | None
|
|
245
254
|
after_hash: str | None
|
|
246
255
|
hunks: tuple[PatchHunkResult, ...] = ()
|
|
256
|
+
predicted_after_hash: str | None = None
|
|
247
257
|
|
|
248
258
|
|
|
249
259
|
@dataclass(frozen=True, slots=True)
|
|
@@ -260,6 +270,12 @@ class MutationResult:
|
|
|
260
270
|
``preview_diff`` carries a dry-run unified diff. ``patch_files`` describes a
|
|
261
271
|
patch application; a recovered failure reports what was rolled back in the
|
|
262
272
|
error details.
|
|
273
|
+
|
|
274
|
+
A dry run reports what was *observed* and what *would* happen, never what
|
|
275
|
+
happened: ``changed`` is ``False``, ``after_hash`` is ``None``, and no
|
|
276
|
+
operation is recorded. ``would_change`` says whether a real run would alter
|
|
277
|
+
anything, and ``predicted_after_hash`` carries the digest the content would
|
|
278
|
+
have -- a prediction, never an observation.
|
|
263
279
|
"""
|
|
264
280
|
|
|
265
281
|
workspace: str
|
|
@@ -271,6 +287,8 @@ class MutationResult:
|
|
|
271
287
|
operation_id: str | None = None
|
|
272
288
|
undo_available: bool = False
|
|
273
289
|
dry_run: bool = False
|
|
290
|
+
would_change: bool | None = None
|
|
291
|
+
predicted_after_hash: str | None = None
|
|
274
292
|
warnings: tuple[str, ...] = ()
|
|
275
293
|
items: tuple[OperationItemResult, ...] = ()
|
|
276
294
|
affected_count: int | None = None
|
|
@@ -285,9 +303,65 @@ class MutationResult:
|
|
|
285
303
|
return payload
|
|
286
304
|
|
|
287
305
|
|
|
306
|
+
def as_dry_run(
|
|
307
|
+
result: MutationResult,
|
|
308
|
+
*,
|
|
309
|
+
would_change: bool,
|
|
310
|
+
predicted_after_hash: str | None = None,
|
|
311
|
+
) -> MutationResult:
|
|
312
|
+
"""Return the honest shape of a dry run for ``result``.
|
|
313
|
+
|
|
314
|
+
This is the single place the dry-run contract is applied: nothing was
|
|
315
|
+
written, so the result reports ``changed=False``, no observed ``after_hash``
|
|
316
|
+
or item size, no operation id and nothing undoable. The prediction a caller
|
|
317
|
+
may legitimately want survives as ``predicted_after_hash`` on the result,
|
|
318
|
+
each item and each patch file.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
return replace(
|
|
322
|
+
result,
|
|
323
|
+
changed=False,
|
|
324
|
+
after_hash=None,
|
|
325
|
+
operation_id=None,
|
|
326
|
+
undo_available=False,
|
|
327
|
+
would_change=would_change,
|
|
328
|
+
predicted_after_hash=predicted_after_hash,
|
|
329
|
+
items=tuple(
|
|
330
|
+
replace(
|
|
331
|
+
item,
|
|
332
|
+
after_hash=None,
|
|
333
|
+
size_bytes=None,
|
|
334
|
+
predicted_after_hash=(
|
|
335
|
+
item.predicted_after_hash
|
|
336
|
+
if item.predicted_after_hash is not None
|
|
337
|
+
else item.after_hash
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
for item in result.items
|
|
341
|
+
),
|
|
342
|
+
patch_files=tuple(
|
|
343
|
+
replace(
|
|
344
|
+
patch_file,
|
|
345
|
+
applied=False,
|
|
346
|
+
after_hash=None,
|
|
347
|
+
predicted_after_hash=(
|
|
348
|
+
patch_file.predicted_after_hash
|
|
349
|
+
if patch_file.predicted_after_hash is not None
|
|
350
|
+
else patch_file.after_hash
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
for patch_file in result.patch_files
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
288
358
|
def _jsonable(value: Any) -> Any:
|
|
289
359
|
if isinstance(value, dict):
|
|
290
|
-
return {
|
|
360
|
+
return {
|
|
361
|
+
key: _jsonable(item)
|
|
362
|
+
for key, item in value.items()
|
|
363
|
+
if not (item is None and key in {"would_change", "predicted_after_hash"})
|
|
364
|
+
}
|
|
291
365
|
if isinstance(value, (list, tuple)):
|
|
292
366
|
return [_jsonable(item) for item in value]
|
|
293
367
|
return value
|
|
@@ -15,22 +15,42 @@ from pathlib import Path
|
|
|
15
15
|
|
|
16
16
|
from ..errors import (
|
|
17
17
|
AlreadyExistsError,
|
|
18
|
+
CallerSurface,
|
|
18
19
|
FilekeeperError,
|
|
19
20
|
PathNotFoundError,
|
|
20
21
|
StorageError,
|
|
21
22
|
ValidationError,
|
|
23
|
+
override_hint,
|
|
22
24
|
storage_failure,
|
|
23
25
|
)
|
|
24
26
|
from ..errors import (
|
|
25
27
|
NotADirectoryError as FilekeeperNotADirectoryError,
|
|
26
28
|
)
|
|
27
29
|
from ..history.journal import OperationJournal, PendingItem
|
|
28
|
-
from ..models import MutationResult, OperationItemResult
|
|
30
|
+
from ..models import MutationResult, OperationItemResult, as_dry_run
|
|
29
31
|
from ..paths import ensure_parent_directory, resolve_path
|
|
30
32
|
from ..safety.atomic import fsync_directory
|
|
31
33
|
from ..safety.hashing import hash_file
|
|
32
34
|
from ..workspace import WorkspaceHandle
|
|
33
35
|
|
|
36
|
+
DIRECTORY_PARENTS_HINT = override_hint(
|
|
37
|
+
core="pass parents=True to create ancestors",
|
|
38
|
+
cli="pass --parents to create ancestors",
|
|
39
|
+
mcp="pass parents=true to create ancestors",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DIRECTORY_DELETE_HINT = override_hint(
|
|
43
|
+
core="pass recursive=True and confirm_recursive=True to delete its contents",
|
|
44
|
+
cli="pass --recursive --confirm-recursive to delete its contents",
|
|
45
|
+
mcp="pass recursive=true and confirm_recursive=true to delete its contents",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
DIRECTORY_CONFIRM_HINT = override_hint(
|
|
49
|
+
core="pass confirm_recursive=True to acknowledge the tree deletion",
|
|
50
|
+
cli="pass --confirm-recursive to acknowledge the tree deletion",
|
|
51
|
+
mcp="pass confirm_recursive=true to acknowledge the tree deletion",
|
|
52
|
+
)
|
|
53
|
+
|
|
34
54
|
|
|
35
55
|
def _mode(path: Path) -> int | None:
|
|
36
56
|
try:
|
|
@@ -97,8 +117,10 @@ def create_directory(
|
|
|
97
117
|
target = resolved.absolute
|
|
98
118
|
if not parents and not target.parent.is_dir():
|
|
99
119
|
raise ValidationError(
|
|
100
|
-
"parent directory does not exist;
|
|
120
|
+
"parent directory does not exist; "
|
|
121
|
+
f"{DIRECTORY_PARENTS_HINT.sentence(CallerSurface.CORE)}",
|
|
101
122
|
details={"path": resolved.relative},
|
|
123
|
+
override_hint=DIRECTORY_PARENTS_HINT,
|
|
102
124
|
)
|
|
103
125
|
|
|
104
126
|
created: list[Path] = [target]
|
|
@@ -151,7 +173,7 @@ def create_directory(
|
|
|
151
173
|
if journal is not None and operation_id is not None:
|
|
152
174
|
journal.commit(operation_id)
|
|
153
175
|
|
|
154
|
-
|
|
176
|
+
result = MutationResult(
|
|
155
177
|
workspace=handle.name,
|
|
156
178
|
path=resolved.relative,
|
|
157
179
|
changed=True,
|
|
@@ -164,6 +186,9 @@ def create_directory(
|
|
|
164
186
|
items=(OperationItemResult(path=resolved.relative, change="created"),),
|
|
165
187
|
affected_count=1,
|
|
166
188
|
)
|
|
189
|
+
if dry_run:
|
|
190
|
+
return as_dry_run(result, would_change=True)
|
|
191
|
+
return result
|
|
167
192
|
|
|
168
193
|
|
|
169
194
|
def _umask() -> int:
|
|
@@ -310,7 +335,7 @@ def move_directory(
|
|
|
310
335
|
},
|
|
311
336
|
) from exc
|
|
312
337
|
|
|
313
|
-
|
|
338
|
+
result = MutationResult(
|
|
314
339
|
workspace=handle.name,
|
|
315
340
|
path=destination_resolved.relative,
|
|
316
341
|
changed=True,
|
|
@@ -330,6 +355,9 @@ def move_directory(
|
|
|
330
355
|
affected_count=1,
|
|
331
356
|
history_skipped=tuple(sorted(set(skipped))),
|
|
332
357
|
)
|
|
358
|
+
if dry_run:
|
|
359
|
+
return as_dry_run(result, would_change=True)
|
|
360
|
+
return result
|
|
333
361
|
|
|
334
362
|
|
|
335
363
|
def _count_entries(root: Path) -> int:
|
|
@@ -370,15 +398,17 @@ def delete_directory(
|
|
|
370
398
|
entries = _count_entries(target)
|
|
371
399
|
if entries > 0 and not recursive:
|
|
372
400
|
raise ValidationError(
|
|
373
|
-
"directory is not empty;
|
|
374
|
-
"
|
|
401
|
+
"directory is not empty; "
|
|
402
|
+
f"{DIRECTORY_DELETE_HINT.sentence(CallerSurface.CORE)}",
|
|
375
403
|
details={"path": resolved.relative, "entries": entries},
|
|
404
|
+
override_hint=DIRECTORY_DELETE_HINT,
|
|
376
405
|
)
|
|
377
406
|
if entries > 0 and not confirm_recursive:
|
|
378
407
|
raise ValidationError(
|
|
379
|
-
"recursive deletion requires explicit confirmation "
|
|
380
|
-
"(
|
|
408
|
+
"recursive deletion requires explicit confirmation: "
|
|
409
|
+
f"{DIRECTORY_CONFIRM_HINT.sentence(CallerSurface.CORE)}",
|
|
381
410
|
details={"path": resolved.relative, "entries": entries},
|
|
411
|
+
override_hint=DIRECTORY_CONFIRM_HINT,
|
|
382
412
|
)
|
|
383
413
|
|
|
384
414
|
operation_id: str | None = None
|
|
@@ -448,7 +478,7 @@ def delete_directory(
|
|
|
448
478
|
if journal is not None and operation_id is not None:
|
|
449
479
|
journal.commit(operation_id)
|
|
450
480
|
|
|
451
|
-
|
|
481
|
+
result = MutationResult(
|
|
452
482
|
workspace=handle.name,
|
|
453
483
|
path=resolved.relative,
|
|
454
484
|
changed=True,
|
|
@@ -473,3 +503,6 @@ def delete_directory(
|
|
|
473
503
|
else warnings,
|
|
474
504
|
history_skipped=skipped,
|
|
475
505
|
)
|
|
506
|
+
if dry_run:
|
|
507
|
+
return as_dry_run(result, would_change=True)
|
|
508
|
+
return result
|
|
@@ -49,6 +49,7 @@ from ..models import (
|
|
|
49
49
|
OperationItemResult,
|
|
50
50
|
PatchFileResult,
|
|
51
51
|
PatchHunkResult,
|
|
52
|
+
as_dry_run,
|
|
52
53
|
)
|
|
53
54
|
from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
|
|
54
55
|
from ..safety.atomic import atomic_write_bytes, fsync_directory
|
|
@@ -682,10 +683,12 @@ class _Plan:
|
|
|
682
683
|
hunks: tuple[PatchHunkResult, ...]
|
|
683
684
|
|
|
684
685
|
|
|
685
|
-
def _syntax(message: str, line: int) -> PatchFailedError:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
686
|
+
def _syntax(message: str, line: int, **counts: int) -> PatchFailedError:
|
|
687
|
+
"""A syntax refusal; ``counts`` carries the hunk's expected/actual line counts."""
|
|
688
|
+
|
|
689
|
+
details: dict[str, Any] = {"line": line, "reason": "syntax"}
|
|
690
|
+
details.update(counts)
|
|
691
|
+
return PatchFailedError(f"invalid patch: {message}", details=details)
|
|
689
692
|
|
|
690
693
|
|
|
691
694
|
def _parse_patch(patch_text: str) -> tuple[_FilePatch, ...]:
|
|
@@ -743,13 +746,34 @@ def _parse_patch(patch_text: str) -> tuple[_FilePatch, ...]:
|
|
|
743
746
|
body.append(_BodyLine("+", body_line[1:]))
|
|
744
747
|
remaining_new -= 1
|
|
745
748
|
else:
|
|
749
|
+
found_old = old_count - remaining_old
|
|
750
|
+
found_new = new_count - remaining_new
|
|
746
751
|
raise _syntax(
|
|
747
|
-
"hunk is truncated or has an unknown line prefix"
|
|
752
|
+
"hunk is truncated or has an unknown line prefix "
|
|
753
|
+
f"(header declares {old_count} old and {new_count} new "
|
|
754
|
+
f"lines; the body supplies {found_old} old and "
|
|
755
|
+
f"{found_new} new)",
|
|
748
756
|
index + 1,
|
|
757
|
+
expected_old_lines=old_count,
|
|
758
|
+
expected_new_lines=new_count,
|
|
759
|
+
actual_old_lines=found_old,
|
|
760
|
+
actual_new_lines=found_new,
|
|
749
761
|
)
|
|
750
762
|
index += 1
|
|
751
763
|
if remaining_old != 0 or remaining_new != 0:
|
|
752
|
-
|
|
764
|
+
found_old = old_count - remaining_old
|
|
765
|
+
found_new = new_count - remaining_new
|
|
766
|
+
raise _syntax(
|
|
767
|
+
"hunk line counts do not match its header "
|
|
768
|
+
f"(header declares {old_count} old and {new_count} new "
|
|
769
|
+
f"lines; the patch supplies {found_old} old and "
|
|
770
|
+
f"{found_new} new)",
|
|
771
|
+
index + 1,
|
|
772
|
+
expected_old_lines=old_count,
|
|
773
|
+
expected_new_lines=new_count,
|
|
774
|
+
actual_old_lines=found_old,
|
|
775
|
+
actual_new_lines=found_new,
|
|
776
|
+
)
|
|
753
777
|
if index < len(lines) and lines[index].startswith("\\"):
|
|
754
778
|
if not body:
|
|
755
779
|
raise _syntax("newline marker has no preceding line", index + 1)
|
|
@@ -1120,19 +1144,23 @@ def apply_patch(
|
|
|
1120
1144
|
)
|
|
1121
1145
|
|
|
1122
1146
|
if dry_run:
|
|
1123
|
-
return
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1147
|
+
return as_dry_run(
|
|
1148
|
+
MutationResult(
|
|
1149
|
+
workspace=handle.name,
|
|
1150
|
+
path=primary.relative,
|
|
1151
|
+
changed=True,
|
|
1152
|
+
before_hash=primary.before_hash,
|
|
1153
|
+
after_hash=primary.after_hash,
|
|
1154
|
+
summary=f"Would apply a patch touching {file_count} file(s).",
|
|
1155
|
+
dry_run=True,
|
|
1156
|
+
warnings=tuple(warnings),
|
|
1157
|
+
items=item_results,
|
|
1158
|
+
affected_count=file_count,
|
|
1159
|
+
preview_diff=patch_text,
|
|
1160
|
+
patch_files=file_results,
|
|
1161
|
+
),
|
|
1162
|
+
would_change=True,
|
|
1163
|
+
predicted_after_hash=primary.after_hash,
|
|
1136
1164
|
)
|
|
1137
1165
|
|
|
1138
1166
|
pending = [
|
|
@@ -21,9 +21,11 @@ import codecs
|
|
|
21
21
|
import os
|
|
22
22
|
import stat
|
|
23
23
|
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
24
25
|
|
|
25
26
|
from ..errors import (
|
|
26
27
|
AlreadyExistsError,
|
|
28
|
+
CallerSurface,
|
|
27
29
|
ContentConflictError,
|
|
28
30
|
FilekeeperError,
|
|
29
31
|
LimitExceededError,
|
|
@@ -32,10 +34,11 @@ from ..errors import (
|
|
|
32
34
|
StorageError,
|
|
33
35
|
SymlinkWriteRefusedError,
|
|
34
36
|
ValidationError,
|
|
37
|
+
override_hint,
|
|
35
38
|
storage_failure,
|
|
36
39
|
)
|
|
37
40
|
from ..history.journal import OperationJournal, PendingItem
|
|
38
|
-
from ..models import MutationResult, OperationItemResult
|
|
41
|
+
from ..models import MutationResult, OperationItemResult, as_dry_run
|
|
39
42
|
from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
|
|
40
43
|
from ..safety.atomic import atomic_write_bytes, fsync_directory
|
|
41
44
|
from ..safety.hashing import format_digest, hash_bytes, hash_file, hashes_match, normalize_hash
|
|
@@ -44,9 +47,31 @@ from ..safety.textdata import decode_bytes, encode_text, looks_binary
|
|
|
44
47
|
from ..safety.vcs import ignore_warning
|
|
45
48
|
from ..workspace import WorkspaceHandle
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
"pass allow_sensitive_write=True in Core,
|
|
49
|
-
"--i-accept-secret-write-risk,
|
|
50
|
+
SENSITIVE_WRITE_HINT = override_hint(
|
|
51
|
+
core="pass allow_sensitive_write=True in Core",
|
|
52
|
+
cli="pass the CLI flag --i-accept-secret-write-risk",
|
|
53
|
+
mcp="pass the MCP argument i_accept_secret_write_risk=true",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Kept for importers of the earlier name.
|
|
57
|
+
SECRET_FLAG_HINT = SENSITIVE_WRITE_HINT
|
|
58
|
+
|
|
59
|
+
CREATE_OVERWRITE_HINT = override_hint(
|
|
60
|
+
core="pass expected_absent=False to overwrite",
|
|
61
|
+
cli="pass --allow-existing to overwrite",
|
|
62
|
+
mcp="pass expected_absent=false to overwrite",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
COPY_OVERWRITE_HINT = override_hint(
|
|
66
|
+
core="pass expected_destination_hash to replace it deliberately",
|
|
67
|
+
cli="pass --expected-destination-hash to replace it deliberately",
|
|
68
|
+
mcp="pass expected_destination_hash to replace it deliberately",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
MOVE_OVERWRITE_HINT = override_hint(
|
|
72
|
+
core="pass overwrite=True to replace it",
|
|
73
|
+
cli="pass --overwrite to replace it",
|
|
74
|
+
mcp="pass overwrite=true to replace it",
|
|
50
75
|
)
|
|
51
76
|
|
|
52
77
|
|
|
@@ -95,8 +120,10 @@ def _guard_sensitive(
|
|
|
95
120
|
if reason is not None and not allow_sensitive_write:
|
|
96
121
|
raise SensitivePathRefusedError(
|
|
97
122
|
f"refusing to write {resolved.relative}: it looks like credential or key "
|
|
98
|
-
f"material ({reason}); {
|
|
123
|
+
f"material ({reason}); {SENSITIVE_WRITE_HINT.sentence(CallerSurface.CORE)}, "
|
|
124
|
+
"if you really mean to write credential material",
|
|
99
125
|
details={"path": resolved.relative, "reason": reason},
|
|
126
|
+
override_hint=SENSITIVE_WRITE_HINT,
|
|
100
127
|
)
|
|
101
128
|
warnings: list[str] = []
|
|
102
129
|
warning = ignore_warning(handle.root, resolved.absolute, resolved.relative)
|
|
@@ -148,7 +175,17 @@ def _after_write(
|
|
|
148
175
|
journal: OperationJournal | None,
|
|
149
176
|
actor: str | None,
|
|
150
177
|
task_id: str | None,
|
|
178
|
+
metadata: dict[str, Any] | None = None,
|
|
179
|
+
journal_summary: str | None = None,
|
|
151
180
|
) -> MutationResult:
|
|
181
|
+
"""Write *data*, journaling the change when a journal is supplied.
|
|
182
|
+
|
|
183
|
+
``journal_summary`` overrides the summary recorded in the journal without
|
|
184
|
+
changing the summary returned to the caller; ``metadata`` is merged into the
|
|
185
|
+
operation's metadata. Callers that touch more than one path use both to name
|
|
186
|
+
the other path in the durable record without reinterpreting ``target_path``.
|
|
187
|
+
"""
|
|
188
|
+
|
|
152
189
|
after_hash = hash_bytes(data)
|
|
153
190
|
operation_id: str | None = None
|
|
154
191
|
skipped: tuple[str, ...] = ()
|
|
@@ -171,12 +208,13 @@ def _after_write(
|
|
|
171
208
|
begun = journal.begin(
|
|
172
209
|
operation_type=operation_type,
|
|
173
210
|
target_path=resolved.relative,
|
|
174
|
-
summary=f"{past_verb} {resolved.relative}.",
|
|
211
|
+
summary=journal_summary or f"{past_verb} {resolved.relative}.",
|
|
175
212
|
items=[pending],
|
|
176
213
|
actor=actor,
|
|
177
214
|
task_id=task_id,
|
|
178
215
|
before_hash=before_hash,
|
|
179
216
|
after_hash=after_hash,
|
|
217
|
+
metadata=metadata,
|
|
180
218
|
)
|
|
181
219
|
operation_id = begun.operation_id
|
|
182
220
|
skipped = begun.skipped
|
|
@@ -197,7 +235,7 @@ def _after_write(
|
|
|
197
235
|
journal.commit(operation_id)
|
|
198
236
|
undo_available = not skipped
|
|
199
237
|
|
|
200
|
-
|
|
238
|
+
result = MutationResult(
|
|
201
239
|
workspace=handle.name,
|
|
202
240
|
path=resolved.relative,
|
|
203
241
|
changed=True,
|
|
@@ -222,6 +260,13 @@ def _after_write(
|
|
|
222
260
|
affected_count=1,
|
|
223
261
|
history_skipped=skipped,
|
|
224
262
|
)
|
|
263
|
+
if dry_run:
|
|
264
|
+
return as_dry_run(
|
|
265
|
+
result,
|
|
266
|
+
would_change=not before_exists or before_hash != after_hash,
|
|
267
|
+
predicted_after_hash=after_hash,
|
|
268
|
+
)
|
|
269
|
+
return result
|
|
225
270
|
|
|
226
271
|
|
|
227
272
|
def create_file(
|
|
@@ -244,8 +289,10 @@ def create_file(
|
|
|
244
289
|
raise NotAFileError("path is a directory", details={"path": resolved.relative})
|
|
245
290
|
if resolved.exists and expected_absent:
|
|
246
291
|
raise AlreadyExistsError(
|
|
247
|
-
"file already exists;
|
|
292
|
+
"file already exists; "
|
|
293
|
+
f"{CREATE_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
|
|
248
294
|
details={"path": resolved.relative},
|
|
295
|
+
override_hint=CREATE_OVERWRITE_HINT,
|
|
249
296
|
)
|
|
250
297
|
warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
|
|
251
298
|
ensure_parent_directory(resolved)
|
|
@@ -401,9 +448,10 @@ def copy_file(
|
|
|
401
448
|
)
|
|
402
449
|
if expected_destination_hash is None:
|
|
403
450
|
raise AlreadyExistsError(
|
|
404
|
-
"destination already exists;
|
|
405
|
-
"
|
|
451
|
+
"destination already exists; "
|
|
452
|
+
f"{COPY_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
|
|
406
453
|
details={"path": destination_resolved.relative},
|
|
454
|
+
override_hint=COPY_OVERWRITE_HINT,
|
|
407
455
|
)
|
|
408
456
|
wanted = normalize_hash(expected_destination_hash)
|
|
409
457
|
if not hashes_match(wanted, destination_before or ""):
|
|
@@ -446,6 +494,10 @@ def copy_file(
|
|
|
446
494
|
journal=journal,
|
|
447
495
|
actor=actor,
|
|
448
496
|
task_id=task_id,
|
|
497
|
+
metadata={"source_path": source_resolved.relative},
|
|
498
|
+
journal_summary=(
|
|
499
|
+
f"Copied {source_resolved.relative} to {destination_resolved.relative}."
|
|
500
|
+
),
|
|
449
501
|
)
|
|
450
502
|
item = OperationItemResult(
|
|
451
503
|
path=source_resolved.relative,
|
|
@@ -454,7 +506,8 @@ def copy_file(
|
|
|
454
506
|
after_hash=hash_bytes(data),
|
|
455
507
|
size_bytes=len(data),
|
|
456
508
|
)
|
|
457
|
-
|
|
509
|
+
predicted_after_hash = result.predicted_after_hash
|
|
510
|
+
result = MutationResult(
|
|
458
511
|
workspace=result.workspace,
|
|
459
512
|
path=result.path,
|
|
460
513
|
changed=True,
|
|
@@ -472,6 +525,13 @@ def copy_file(
|
|
|
472
525
|
affected_count=1,
|
|
473
526
|
history_skipped=result.history_skipped,
|
|
474
527
|
)
|
|
528
|
+
if dry_run:
|
|
529
|
+
return as_dry_run(
|
|
530
|
+
result,
|
|
531
|
+
would_change=True,
|
|
532
|
+
predicted_after_hash=predicted_after_hash,
|
|
533
|
+
)
|
|
534
|
+
return result
|
|
475
535
|
|
|
476
536
|
|
|
477
537
|
def move_file(
|
|
@@ -524,8 +584,10 @@ def move_file(
|
|
|
524
584
|
)
|
|
525
585
|
if destination_resolved.exists and not overwrite:
|
|
526
586
|
raise AlreadyExistsError(
|
|
527
|
-
"destination already exists;
|
|
587
|
+
"destination already exists; "
|
|
588
|
+
f"{MOVE_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
|
|
528
589
|
details={"path": destination_resolved.relative},
|
|
590
|
+
override_hint=MOVE_OVERWRITE_HINT,
|
|
529
591
|
)
|
|
530
592
|
warnings = _guard_sensitive(
|
|
531
593
|
handle,
|
|
@@ -578,6 +640,7 @@ def move_file(
|
|
|
578
640
|
task_id=task_id,
|
|
579
641
|
before_hash=destination_before,
|
|
580
642
|
after_hash=source_hash,
|
|
643
|
+
metadata={"source_path": source_resolved.relative},
|
|
581
644
|
)
|
|
582
645
|
operation_id = begun.operation_id
|
|
583
646
|
skipped = begun.skipped
|
|
@@ -612,7 +675,7 @@ def move_file(
|
|
|
612
675
|
else:
|
|
613
676
|
after_hash = source_hash
|
|
614
677
|
|
|
615
|
-
|
|
678
|
+
result = MutationResult(
|
|
616
679
|
workspace=handle.name,
|
|
617
680
|
path=destination_resolved.relative,
|
|
618
681
|
changed=True,
|
|
@@ -643,6 +706,13 @@ def move_file(
|
|
|
643
706
|
affected_count=1,
|
|
644
707
|
history_skipped=skipped,
|
|
645
708
|
)
|
|
709
|
+
if dry_run:
|
|
710
|
+
return as_dry_run(
|
|
711
|
+
result,
|
|
712
|
+
would_change=True,
|
|
713
|
+
predicted_after_hash=after_hash,
|
|
714
|
+
)
|
|
715
|
+
return result
|
|
646
716
|
|
|
647
717
|
|
|
648
718
|
def delete_file(
|
|
@@ -725,7 +795,7 @@ def delete_file(
|
|
|
725
795
|
if journal is not None and operation_id is not None:
|
|
726
796
|
journal.commit(operation_id)
|
|
727
797
|
|
|
728
|
-
|
|
798
|
+
result = MutationResult(
|
|
729
799
|
workspace=handle.name,
|
|
730
800
|
path=resolved.relative,
|
|
731
801
|
changed=True,
|
|
@@ -746,6 +816,9 @@ def delete_file(
|
|
|
746
816
|
affected_count=1,
|
|
747
817
|
history_skipped=skipped,
|
|
748
818
|
)
|
|
819
|
+
if dry_run:
|
|
820
|
+
return as_dry_run(result, would_change=True, predicted_after_hash=None)
|
|
821
|
+
return result
|
|
749
822
|
|
|
750
823
|
|
|
751
824
|
# Re-exported for adapters that need to format a hash without importing safety.
|
|
@@ -26,7 +26,12 @@ from .config import (
|
|
|
26
26
|
load_config,
|
|
27
27
|
resolve_home,
|
|
28
28
|
)
|
|
29
|
-
from .errors import
|
|
29
|
+
from .errors import (
|
|
30
|
+
CallerSurface,
|
|
31
|
+
NoWorkspaceSelectedError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
override_hint,
|
|
34
|
+
)
|
|
30
35
|
from .history.blobs import BlobStore
|
|
31
36
|
from .history.journal import OperationJournal
|
|
32
37
|
from .history.repository import diff_operation as read_diff
|
|
@@ -58,6 +63,12 @@ from .workspace import (
|
|
|
58
63
|
TRANSIENT_ID_PREFIX = "ws-transient-"
|
|
59
64
|
TRANSIENT_ID_LENGTH = 16
|
|
60
65
|
|
|
66
|
+
WORKSPACE_SELECTION_HINT = override_hint(
|
|
67
|
+
core="select a workspace explicitly or register a default",
|
|
68
|
+
cli="pass --workspace or --root, or register a default with 'keeper workspace use'",
|
|
69
|
+
mcp="start the server with --root PATH or register a default workspace",
|
|
70
|
+
)
|
|
71
|
+
|
|
61
72
|
|
|
62
73
|
def transient_workspace_id(root: str | Path) -> str:
|
|
63
74
|
"""A stable id for the transient workspace rooted at ``root``.
|
|
@@ -173,10 +184,11 @@ class Filekeeper:
|
|
|
173
184
|
return FilekeeperService(handle_for(workspaces[0], self.config, self.home), self)
|
|
174
185
|
available = ", ".join(workspace.name for workspace in workspaces)
|
|
175
186
|
raise NoWorkspaceSelectedError(
|
|
176
|
-
"no workspace selected:
|
|
177
|
-
"
|
|
187
|
+
"no workspace selected: "
|
|
188
|
+
f"{WORKSPACE_SELECTION_HINT.sentence(CallerSurface.CORE)}"
|
|
178
189
|
+ (f"; known workspaces: {available}" if available else ""),
|
|
179
190
|
details={"known_workspaces": available},
|
|
191
|
+
override_hint=WORKSPACE_SELECTION_HINT,
|
|
180
192
|
)
|
|
181
193
|
return FilekeeperService(handle_for(default, self.config, self.home), self)
|
|
182
194
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|