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.
Files changed (36) hide show
  1. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/PKG-INFO +43 -7
  2. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/README.md +42 -6
  3. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/pyproject.toml +1 -1
  4. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/pyproject.toml.orig +1 -1
  5. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/__init__.py +5 -1
  6. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/output.py +13 -1
  7. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/__init__.py +3 -1
  8. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/errors.py +65 -2
  9. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/journal.py +4 -0
  10. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/undo.py +90 -6
  11. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/models.py +77 -3
  12. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/directory.py +42 -9
  13. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/edit.py +47 -19
  14. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/write.py +87 -14
  15. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/service.py +15 -3
  16. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/LICENSE +0 -0
  17. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/__init__.py +0 -0
  18. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/app.py +0 -0
  19. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/cli/exit_codes.py +0 -0
  20. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/config.py +0 -0
  21. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/database.py +0 -0
  22. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/diffing.py +0 -0
  23. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/__init__.py +0 -0
  24. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/blobs.py +0 -0
  25. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/history/repository.py +0 -0
  26. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/__init__.py +0 -0
  27. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/read.py +0 -0
  28. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/operations/search.py +0 -0
  29. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/paths.py +0 -0
  30. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/__init__.py +0 -0
  31. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/atomic.py +0 -0
  32. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/hashing.py +0 -0
  33. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/sensitive.py +0 -0
  34. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/textdata.py +0 -0
  35. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.6}/src/filekeeper/core/safety/vcs.py +0 -0
  36. {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.4
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 (a partially applied multi-file
308
- operation), undo leaves it alone;
309
- - if it matches neither, someone changed it afterwards: the undo is refused with
310
- `UNDO_CONFLICT` (exit code 12) and **nothing is modified**.
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` (Core: `allow_sensitive_write=True`). The flag is
439
- long because the mistake is expensive.
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 (a partially applied multi-file
287
- operation), undo leaves it alone;
288
- - if it matches neither, someone changed it afterwards: the undo is refused with
289
- `UNDO_CONFLICT` (exit code 12) and **nothing is modified**.
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` (Core: `allow_sensitive_write=True`). The flag is
418
- long because the mistake is expensive.
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -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.4"
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__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
139
- super().__init__(message, details=details)
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 COMMITTED, FAILED, UNDONE, OperationJournal, PendingItem
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, so nothing was modified",
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": ", ".join(sorted(conflicts)),
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
- steps = _plan(journal, record)
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
- clauses = ["status IN (?, ?)"]
320
- parameters: list[object] = [COMMITTED, FAILED]
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 {key: _jsonable(item) for key, item in value.items()}
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; pass parents=True to create ancestors",
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
- return MutationResult(
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
- return MutationResult(
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; pass --recursive --confirm-recursive "
374
- "to delete its contents",
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
- "(--recursive --confirm-recursive)",
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
- return MutationResult(
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
- return PatchFailedError(
687
- f"invalid patch: {message}", details={"line": line, "reason": "syntax"}
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
- raise _syntax("hunk line counts do not match its header", index + 1)
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 MutationResult(
1124
- workspace=handle.name,
1125
- path=primary.relative,
1126
- changed=True,
1127
- before_hash=primary.before_hash,
1128
- after_hash=primary.after_hash,
1129
- summary=f"Would apply a patch touching {file_count} file(s).",
1130
- dry_run=True,
1131
- warnings=tuple(warnings),
1132
- items=item_results,
1133
- affected_count=file_count,
1134
- preview_diff=patch_text,
1135
- patch_files=file_results,
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
- SECRET_FLAG_HINT = (
48
- "pass allow_sensitive_write=True in Core, or the CLI flag "
49
- "--i-accept-secret-write-risk, if you really mean to write credential material"
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}); {SECRET_FLAG_HINT}",
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
- return MutationResult(
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; pass expected_absent=False to overwrite",
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; pass expected_destination_hash to "
405
- "replace it deliberately",
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
- return MutationResult(
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; pass overwrite=True to replace it",
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
- return MutationResult(
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
- return MutationResult(
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 NoWorkspaceSelectedError, ValidationError
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: pass --workspace or --root, or register a "
177
- "default with 'keeper workspace use'"
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