filekeeper-cli 0.1.7__tar.gz → 0.1.8__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.7 → filekeeper_cli-0.1.8}/PKG-INFO +11 -1
  2. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/README.md +10 -0
  3. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/pyproject.toml +1 -1
  4. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/pyproject.toml.orig +1 -1
  5. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/__init__.py +1 -1
  6. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/app.py +13 -8
  7. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/output.py +4 -3
  8. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/config.py +1 -2
  9. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/errors.py +1 -3
  10. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/blobs.py +1 -3
  11. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/journal.py +2 -6
  12. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/repository.py +33 -5
  13. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/undo.py +5 -8
  14. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/models.py +1 -0
  15. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/directory.py +10 -14
  16. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/edit.py +18 -50
  17. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/read.py +7 -7
  18. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/search.py +5 -5
  19. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/write.py +16 -26
  20. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/paths.py +24 -0
  21. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/service.py +1 -3
  22. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/workspace.py +5 -7
  23. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/LICENSE +0 -0
  24. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/__init__.py +0 -0
  25. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/exit_codes.py +0 -0
  26. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/__init__.py +0 -0
  27. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/database.py +0 -0
  28. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/diffing.py +0 -0
  29. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/__init__.py +0 -0
  30. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/__init__.py +0 -0
  31. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/__init__.py +0 -0
  32. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/atomic.py +0 -0
  33. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/hashing.py +0 -0
  34. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/sensitive.py +0 -0
  35. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/textdata.py +0 -0
  36. {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/vcs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filekeeper-cli
3
- Version: 0.1.7
3
+ Version: 0.1.8
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
@@ -160,6 +160,16 @@ If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
160
160
  `.gitignore` when it creates the state directory. Outside a repository it writes
161
161
  nothing.
162
162
 
163
+ **Reported paths when the root is `/`.** Result paths are normally
164
+ workspace-relative, so they are the same whichever tree holds them. With a root
165
+ of `/` that form is indistinguishable from a repository-relative path, so every
166
+ reported path keeps its leading `/` and is exactly the absolute path: read
167
+ results, `file_info`, `file_read_many` entries, search results, directory
168
+ listings, copy/move result paths, and history target and item paths. Every other
169
+ root is unchanged, byte for byte. History rows are stored relative and rendered
170
+ when read, so existing records display correctly without a migration. Error
171
+ envelopes do not change.
172
+
163
173
  ## Durability guarantees, in plain words
164
174
 
165
175
  - **One file, replaced at a time, is atomic.** A whole-file write goes to a
@@ -139,6 +139,16 @@ If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
139
139
  `.gitignore` when it creates the state directory. Outside a repository it writes
140
140
  nothing.
141
141
 
142
+ **Reported paths when the root is `/`.** Result paths are normally
143
+ workspace-relative, so they are the same whichever tree holds them. With a root
144
+ of `/` that form is indistinguishable from a repository-relative path, so every
145
+ reported path keeps its leading `/` and is exactly the absolute path: read
146
+ results, `file_info`, `file_read_many` entries, search results, directory
147
+ listings, copy/move result paths, and history target and item paths. Every other
148
+ root is unchanged, byte for byte. History rows are stored relative and rendered
149
+ when read, so existing records display correctly without a migration. Error
150
+ envelopes do not change.
151
+
142
152
  ## Durability guarantees, in plain words
143
153
 
144
154
  - **One file, replaced at a time, is atomic.** A whole-file write goes to a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.7"
3
+ version = "0.1.8"
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.7"
3
+ version = "0.1.8"
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"
@@ -23,7 +23,7 @@ from .core import (
23
23
  Workspace,
24
24
  )
25
25
 
26
- __version__ = "0.1.7"
26
+ __version__ = "0.1.8"
27
27
 
28
28
  __all__ = [
29
29
  "CallerSurface",
@@ -482,14 +482,19 @@ def _handle_workspace_list(context: _Context) -> int:
482
482
  output.workspace_summary(workspace, is_default=workspace.name == default_name)
483
483
  for workspace in workspaces
484
484
  ]
485
- context.emit(
486
- {
487
- "ok": True,
488
- "default_workspace": default_name,
489
- "workspaces": output.jsonable(workspaces),
490
- },
491
- "\n".join(lines) if lines else "no workspaces registered",
492
- )
485
+ payload: dict[str, object] = {
486
+ "ok": True,
487
+ "default_workspace": default_name,
488
+ "workspaces": output.jsonable(workspaces),
489
+ }
490
+ warnings = [
491
+ f"workspace '{workspace.name}' is registered at a root that no longer exists"
492
+ for workspace in workspaces
493
+ if not workspace.root_exists
494
+ ]
495
+ if warnings:
496
+ payload["warnings"] = warnings
497
+ context.emit(payload, "\n".join(lines) if lines else "no workspaces registered")
493
498
  return exit_codes.SUCCESS
494
499
 
495
500
 
@@ -163,9 +163,7 @@ def mutation(result: MutationResult) -> str:
163
163
  "(recoverable through history, not atomic across files)"
164
164
  )
165
165
  for file in result.patch_files:
166
- hunks = ", ".join(
167
- f"hunk {hunk.old_start}: {hunk.status}" for hunk in file.hunks
168
- )
166
+ hunks = ", ".join(f"hunk {hunk.old_start}: {hunk.status}" for hunk in file.hunks)
169
167
  lines.append(f" {file.change:<9} {file.path} {hunks}")
170
168
  if result.preview_diff:
171
169
  lines.append("diff:")
@@ -240,6 +238,8 @@ def history_diff(diff: OperationDiff) -> str:
240
238
 
241
239
  def workspace_summary(workspace: Workspace, *, is_default: bool = False) -> str:
242
240
  suffix = " (default)" if is_default else ""
241
+ if not workspace.root_exists:
242
+ suffix += " (root missing)"
243
243
  return f"{workspace.name}\t{workspace.root_path}\t{workspace.id}{suffix}"
244
244
 
245
245
 
@@ -249,6 +249,7 @@ def workspace_detail(workspace: Workspace, *, is_default: bool = False) -> str:
249
249
  f"name: {workspace.name}",
250
250
  f"id: {workspace.id}",
251
251
  f"root: {workspace.root_path}",
252
+ f"root_exists: {str(workspace.root_exists).lower()}",
252
253
  f"created_at: {workspace.created_at}",
253
254
  f"updated_at: {workspace.updated_at}",
254
255
  f"default: {is_default}",
@@ -91,8 +91,7 @@ def load_config(home: Path) -> Config:
91
91
  raw = tomllib.load(handle)
92
92
  except (OSError, tomllib.TOMLDecodeError) as exc:
93
93
  raise StorageError(
94
- f"cannot read configuration file: "
95
- f"{getattr(exc, 'strerror', None) or exc}",
94
+ f"cannot read configuration file: {getattr(exc, 'strerror', None) or exc}",
96
95
  details={"path": str(path)},
97
96
  ) from exc
98
97
 
@@ -76,9 +76,7 @@ class OverrideHint:
76
76
  def override_hint(*, core: str, cli: str, mcp: str) -> OverrideHint:
77
77
  """Build a hint whose sentence names each surface's own argument."""
78
78
 
79
- return OverrideHint(
80
- {CallerSurface.CORE: core, CallerSurface.CLI: cli, CallerSurface.MCP: mcp}
81
- )
79
+ return OverrideHint({CallerSurface.CORE: core, CallerSurface.CLI: cli, CallerSurface.MCP: mcp})
82
80
 
83
81
 
84
82
  class FilekeeperError(Exception):
@@ -68,9 +68,7 @@ class BlobStore:
68
68
  "blob is missing from the store", details={"hash": normalize_hash(digest)}
69
69
  ) from exc
70
70
  except OSError as exc: # pragma: no cover - filesystem failure
71
- raise StorageError(
72
- f"cannot read blob: {exc.strerror or type(exc).__name__}"
73
- ) from exc
71
+ raise StorageError(f"cannot read blob: {exc.strerror or type(exc).__name__}") from exc
74
72
  actual = hash_bytes(data)
75
73
  if actual != normalize_hash(digest):
76
74
  raise StorageError(
@@ -321,9 +321,7 @@ def _load_metadata(raw: str | None) -> dict[str, Any]:
321
321
 
322
322
 
323
323
  def _current_state(root: Path, item: sqlite3.Row | OperationItemRecord) -> tuple[bool, str | None]:
324
- relative = (
325
- str(item["relative_path"]) if isinstance(item, sqlite3.Row) else item.relative_path
326
- )
324
+ relative = str(item["relative_path"]) if isinstance(item, sqlite3.Row) else item.relative_path
327
325
  path = root / relative
328
326
  if str(item["item_type"] if isinstance(item, sqlite3.Row) else item.item_type) == "directory":
329
327
  return path.is_dir(), None
@@ -351,9 +349,7 @@ def _matches(
351
349
  return current_hash == digest
352
350
 
353
351
 
354
- def _classify_pending(
355
- root: Path, items: list[sqlite3.Row]
356
- ) -> tuple[str, list[str]]:
352
+ def _classify_pending(root: Path, items: list[sqlite3.Row]) -> tuple[str, list[str]]:
357
353
  if not items:
358
354
  return FAILED, []
359
355
  all_after = True
@@ -3,21 +3,45 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sqlite3
6
+ from dataclasses import replace
7
+ from pathlib import Path
6
8
  from typing import Any
7
9
 
8
10
  from ..diffing import unified_diff_text
9
11
  from ..errors import HistoryUnavailableError, OperationNotFoundError, StorageError
10
12
  from ..models import DiffEntry, OperationDiff, OperationItemRecord, OperationRecord
13
+ from ..paths import reported_path
11
14
  from .journal import OperationJournal, _row_to_operation
12
15
 
13
16
 
17
+ def _reported_record(root: Path, record: OperationRecord) -> OperationRecord:
18
+ """Render a stored record's paths for a caller, without migrating the row.
19
+
20
+ History stores workspace-relative paths; a workspace rooted at ``/`` reports
21
+ them with their leading separator so the string is the absolute path. Every
22
+ other root renders byte-identically to what was stored.
23
+ """
24
+
25
+ return replace(
26
+ record,
27
+ target_path=(
28
+ None if record.target_path is None else reported_path(root, record.target_path)
29
+ ),
30
+ items=tuple(
31
+ replace(item, relative_path=reported_path(root, item.relative_path))
32
+ for item in record.items
33
+ ),
34
+ history_skipped=tuple(reported_path(root, path) for path in record.history_skipped),
35
+ )
36
+
37
+
14
38
  def get_operation(journal: OperationJournal, operation_id: str) -> OperationRecord:
15
39
  record = journal.get(operation_id)
16
40
  if record is None:
17
41
  raise OperationNotFoundError(
18
42
  "no operation with that id in this workspace", details={"operation_id": operation_id}
19
43
  )
20
- return record
44
+ return _reported_record(journal.root, record)
21
45
 
22
46
 
23
47
  def _matching_operations(journal: OperationJournal, path: str | None) -> list[str]:
@@ -80,7 +104,7 @@ def list_history(
80
104
  "SELECT * FROM operation_items WHERE operation_id = ? ORDER BY id",
81
105
  (row["id"],),
82
106
  ).fetchall()
83
- records.append(_row_to_operation(row, items))
107
+ records.append(_reported_record(journal.root, _row_to_operation(row, items)))
84
108
  except sqlite3.Error as exc:
85
109
  raise StorageError(f"cannot read operation history: {exc}") from exc
86
110
  finally:
@@ -116,7 +140,7 @@ def _diff_entry(journal: OperationJournal, item: OperationItemRecord) -> DiffEnt
116
140
  after_text = _decode(after)
117
141
  if before_text is None or after_text is None:
118
142
  return DiffEntry(
119
- path=item.relative_path,
143
+ path=reported_path(journal.root, item.relative_path),
120
144
  item_type=item.item_type,
121
145
  before_exists=item.before_exists,
122
146
  after_exists=item.after_exists,
@@ -130,7 +154,7 @@ def _diff_entry(journal: OperationJournal, item: OperationItemRecord) -> DiffEnt
130
154
  tofile=f"b/{item.relative_path}" if item.after_exists else "/dev/null",
131
155
  )
132
156
  return DiffEntry(
133
- path=item.relative_path,
157
+ path=reported_path(journal.root, item.relative_path),
134
158
  item_type=item.item_type,
135
159
  before_exists=item.before_exists,
136
160
  after_exists=item.after_exists,
@@ -140,7 +164,11 @@ def _diff_entry(journal: OperationJournal, item: OperationItemRecord) -> DiffEnt
140
164
 
141
165
 
142
166
  def diff_operation(journal: OperationJournal, operation_id: str) -> OperationDiff:
143
- record = get_operation(journal, operation_id)
167
+ record = journal.get(operation_id)
168
+ if record is None:
169
+ raise OperationNotFoundError(
170
+ "no operation with that id in this workspace", details={"operation_id": operation_id}
171
+ )
144
172
  return OperationDiff(
145
173
  operation_id=record.operation_id,
146
174
  items=tuple(
@@ -26,6 +26,7 @@ from ..errors import (
26
26
  storage_failure,
27
27
  )
28
28
  from ..models import MutationResult, OperationItemRecord, OperationItemResult, OperationRecord
29
+ from ..paths import reported_path
29
30
  from ..safety.atomic import atomic_write_bytes, fsync_directory
30
31
  from ..safety.hashing import hash_file
31
32
  from .journal import (
@@ -225,9 +226,7 @@ def _apply(journal: OperationJournal, steps: list[_Step]) -> None:
225
226
  path.unlink()
226
227
  fsync_directory(path.parent)
227
228
  except OSError as exc:
228
- raise storage_failure(
229
- "remove file during undo", step.item.relative_path, exc
230
- ) from exc
229
+ raise storage_failure("remove file during undo", step.item.relative_path, exc) from exc
231
230
 
232
231
  directories = [step for step in removable if step.action == "remove-directory"]
233
232
  for step in sorted(
@@ -271,7 +270,7 @@ def _result(
271
270
  ) -> MutationResult:
272
271
  items = tuple(
273
272
  OperationItemResult(
274
- path=step.item.relative_path,
273
+ path=reported_path(journal.root, step.item.relative_path),
275
274
  change=(
276
275
  "deleted"
277
276
  if not step.item.before_exists
@@ -287,7 +286,7 @@ def _result(
287
286
  )
288
287
  return MutationResult(
289
288
  workspace=workspace_name,
290
- path=record.target_path or "",
289
+ path=(reported_path(journal.root, record.target_path) if record.target_path else ""),
291
290
  changed=True,
292
291
  before_hash=record.after_hash,
293
292
  after_hash=record.before_hash,
@@ -393,9 +392,7 @@ def undo_operation(
393
392
  raise
394
393
  journal.commit(begun.operation_id)
395
394
  journal._set_status(record.operation_id, UNDONE)
396
- return _result(
397
- journal, record, begun.operation_id, workspace_name=workspace_name, steps=steps
398
- )
395
+ return _result(journal, record, begun.operation_id, workspace_name=workspace_name, steps=steps)
399
396
 
400
397
 
401
398
  def latest_undoable(journal: OperationJournal, *, actor: str | None = None) -> OperationRecord:
@@ -24,6 +24,7 @@ class Workspace:
24
24
  root_path: str
25
25
  created_at: str
26
26
  updated_at: str
27
+ root_exists: bool = True
27
28
 
28
29
 
29
30
  @dataclass(frozen=True, slots=True)
@@ -72,9 +72,7 @@ def _walk_tree(root: Path) -> list[Path]:
72
72
  return found
73
73
 
74
74
 
75
- def _file_bytes(
76
- path: Path, cap: int, *, display_path: str
77
- ) -> tuple[str | None, bytes | None]:
75
+ def _file_bytes(path: Path, cap: int, *, display_path: str) -> tuple[str | None, bytes | None]:
78
76
  try:
79
77
  size = path.stat().st_size
80
78
  except OSError:
@@ -175,7 +173,7 @@ def create_directory(
175
173
 
176
174
  result = MutationResult(
177
175
  workspace=handle.name,
178
- path=resolved.relative,
176
+ path=resolved.reported,
179
177
  changed=True,
180
178
  before_hash=None,
181
179
  after_hash=None,
@@ -183,7 +181,7 @@ def create_directory(
183
181
  operation_id=operation_id,
184
182
  undo_available=not dry_run and operation_id is not None,
185
183
  dry_run=dry_run,
186
- items=(OperationItemResult(path=resolved.relative, change="created"),),
184
+ items=(OperationItemResult(path=resolved.reported, change="created"),),
187
185
  affected_count=1,
188
186
  )
189
187
  if dry_run:
@@ -306,8 +304,7 @@ def move_directory(
306
304
  operation_type="move_directory",
307
305
  target_path=destination_resolved.relative,
308
306
  summary=(
309
- f"Moved directory {source_resolved.relative} "
310
- f"to {destination_resolved.relative}."
307
+ f"Moved directory {source_resolved.relative} to {destination_resolved.relative}."
311
308
  ),
312
309
  items=items,
313
310
  actor=actor,
@@ -337,7 +334,7 @@ def move_directory(
337
334
 
338
335
  result = MutationResult(
339
336
  workspace=handle.name,
340
- path=destination_resolved.relative,
337
+ path=destination_resolved.reported,
341
338
  changed=True,
342
339
  before_hash=None,
343
340
  after_hash=None,
@@ -349,8 +346,8 @@ def move_directory(
349
346
  undo_available=not dry_run and operation_id is not None and not skipped,
350
347
  dry_run=dry_run,
351
348
  items=(
352
- OperationItemResult(path=source_resolved.relative, change="moved"),
353
- OperationItemResult(path=destination_resolved.relative, change="created"),
349
+ OperationItemResult(path=source_resolved.reported, change="moved"),
350
+ OperationItemResult(path=destination_resolved.reported, change="created"),
354
351
  ),
355
352
  affected_count=1,
356
353
  history_skipped=tuple(sorted(set(skipped))),
@@ -398,8 +395,7 @@ def delete_directory(
398
395
  entries = _count_entries(target)
399
396
  if entries > 0 and not recursive:
400
397
  raise ValidationError(
401
- "directory is not empty; "
402
- f"{DIRECTORY_DELETE_HINT.sentence(CallerSurface.CORE)}",
398
+ f"directory is not empty; {DIRECTORY_DELETE_HINT.sentence(CallerSurface.CORE)}",
403
399
  details={"path": resolved.relative, "entries": entries},
404
400
  override_hint=DIRECTORY_DELETE_HINT,
405
401
  )
@@ -480,7 +476,7 @@ def delete_directory(
480
476
 
481
477
  result = MutationResult(
482
478
  workspace=handle.name,
483
- path=resolved.relative,
479
+ path=resolved.reported,
484
480
  changed=True,
485
481
  before_hash=None,
486
482
  after_hash=None,
@@ -491,7 +487,7 @@ def delete_directory(
491
487
  operation_id=operation_id,
492
488
  undo_available=not dry_run and operation_id is not None and not skipped,
493
489
  dry_run=dry_run,
494
- items=(OperationItemResult(path=resolved.relative, change="deleted"),),
490
+ items=(OperationItemResult(path=resolved.reported, change="deleted"),),
495
491
  affected_count=entries,
496
492
  warnings=(
497
493
  *warnings,
@@ -51,7 +51,7 @@ from ..models import (
51
51
  PatchHunkResult,
52
52
  as_dry_run,
53
53
  )
54
- from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
54
+ from ..paths import ResolvedPath, ensure_parent_directory, reported_path, resolve_path
55
55
  from ..safety.atomic import atomic_write_bytes, fsync_directory
56
56
  from ..safety.hashing import hash_bytes, hashes_match, normalize_hash
57
57
  from ..safety.textdata import TextProfile, decode_bytes, encode_text
@@ -106,9 +106,7 @@ def _load(
106
106
  expect="file",
107
107
  state_dir=handle.state_dir,
108
108
  )
109
- warnings = _guard_sensitive(
110
- handle, resolved, allow_sensitive_write=allow_sensitive_write
111
- )
109
+ warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
112
110
  try:
113
111
  raw = resolved.absolute.read_bytes()
114
112
  except OSError as exc:
@@ -220,10 +218,7 @@ def _candidates(text: str, old: str) -> list[dict[str, Any]]:
220
218
  ratio = difflib.SequenceMatcher(None, probe, line.text.strip()).ratio()
221
219
  scored.append((ratio, index, line.text))
222
220
  scored.sort(key=lambda item: item[0], reverse=True)
223
- return [
224
- {"line": number, "text": content}
225
- for _, number, content in scored[:3]
226
- ]
221
+ return [{"line": number, "text": content} for _, number, content in scored[:3]]
227
222
 
228
223
 
229
224
  def _finish(
@@ -241,9 +236,7 @@ def _finish(
241
236
  actor: str | None,
242
237
  task_id: str | None,
243
238
  ) -> MutationResult:
244
- data = encode_text(
245
- new_text, encoding=loaded.profile.encoding, has_bom=loaded.profile.has_bom
246
- )
239
+ data = encode_text(new_text, encoding=loaded.profile.encoding, has_bom=loaded.profile.has_bom)
247
240
  _check_size(handle, data, loaded.resolved.relative)
248
241
 
249
242
  preview: str | None = None
@@ -337,9 +330,7 @@ def replace_text(
337
330
  """
338
331
 
339
332
  if match not in _MATCH_POLICIES:
340
- raise ValidationError(
341
- "match policy must be 'first' or 'all'", details={"match": match}
342
- )
333
+ raise ValidationError("match policy must be 'first' or 'all'", details={"match": match})
343
334
  if old == "":
344
335
  raise ValidationError("replacement target must not be empty")
345
336
  if expected_match_count is not None and expected_match_count < 0:
@@ -489,17 +480,11 @@ def replace_lines(
489
480
  newline = _newline_for(loaded.profile)
490
481
  replacement_lines = _replacement_lines(replacement, newline)
491
482
  if replacement_lines and not replacement.endswith(("\n", "\r")):
492
- replacement_lines[-1] = DiffLine(
493
- replacement_lines[-1].text, lines[end_line - 1].ending
494
- )
483
+ replacement_lines[-1] = DiffLine(replacement_lines[-1].text, lines[end_line - 1].ending)
495
484
  new_lines = lines[: start_line - 1] + replacement_lines + lines[end_line:]
496
485
  new_text = join_lines(new_lines)
497
486
 
498
- span = (
499
- f"line {start_line}"
500
- if start_line == end_line
501
- else f"lines {start_line}-{end_line}"
502
- )
487
+ span = f"line {start_line}" if start_line == end_line else f"lines {start_line}-{end_line}"
503
488
  return _finish(
504
489
  handle,
505
490
  loaded,
@@ -620,11 +605,7 @@ def delete_lines(
620
605
 
621
606
  new_lines = lines[: start_line - 1] + lines[end_line:]
622
607
  new_text = join_lines(new_lines)
623
- span = (
624
- f"line {start_line}"
625
- if start_line == end_line
626
- else f"lines {start_line}-{end_line}"
627
- )
608
+ span = f"line {start_line}" if start_line == end_line else f"lines {start_line}-{end_line}"
628
609
  return _finish(
629
610
  handle,
630
611
  loaded,
@@ -779,9 +760,7 @@ def _parse_patch(patch_text: str) -> tuple[_FilePatch, ...]:
779
760
  raise _syntax("newline marker has no preceding line", index + 1)
780
761
  body[-1] = dataclasses.replace(body[-1], terminated=False)
781
762
  index += 1
782
- hunks.append(
783
- _Hunk(old_start, old_count, new_start, new_count, tuple(body))
784
- )
763
+ hunks.append(_Hunk(old_start, old_count, new_start, new_count, tuple(body)))
785
764
  continue
786
765
  if current.strip() == "" or current.startswith(_METADATA_PREFIXES):
787
766
  index += 1
@@ -922,9 +901,7 @@ def _plan_file(
922
901
  for_write=True,
923
902
  state_dir=handle.state_dir,
924
903
  )
925
- warnings = _guard_sensitive(
926
- handle, resolved, allow_sensitive_write=allow_sensitive_write
927
- )
904
+ warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
928
905
 
929
906
  newline = ""
930
907
  if create:
@@ -979,9 +956,7 @@ def _plan_file(
979
956
  after_exists = False
980
957
  change: Literal["created", "modified", "deleted", "unchanged"] = "deleted"
981
958
  else:
982
- after_bytes = encode_text(
983
- after_text, encoding=profile_encoding, has_bom=has_bom
984
- )
959
+ after_bytes = encode_text(after_text, encoding=profile_encoding, has_bom=has_bom)
985
960
  _check_size(handle, after_bytes, relative)
986
961
  after_hash = hash_bytes(after_bytes)
987
962
  after_exists = True
@@ -1081,18 +1056,14 @@ def apply_patch(
1081
1056
  """
1082
1057
 
1083
1058
  if not patch_text.strip():
1084
- raise PatchFailedError(
1085
- "invalid patch: the patch is empty", details={"reason": "syntax"}
1086
- )
1059
+ raise PatchFailedError("invalid patch: the patch is empty", details={"reason": "syntax"})
1087
1060
 
1088
1061
  patches = _parse_patch(patch_text)
1089
1062
  seen: set[str] = set()
1090
1063
  plans: list[_Plan] = []
1091
1064
  warnings: list[str] = []
1092
1065
  for patch in patches:
1093
- plan, plan_warnings = _plan_file(
1094
- handle, patch, allow_sensitive_write=allow_sensitive_write
1095
- )
1066
+ plan, plan_warnings = _plan_file(handle, patch, allow_sensitive_write=allow_sensitive_write)
1096
1067
  if plan.relative in seen:
1097
1068
  raise PatchFailedError(
1098
1069
  f"the patch names {plan.relative} more than once",
@@ -1106,10 +1077,7 @@ def apply_patch(
1106
1077
  plans = [
1107
1078
  dataclasses.replace(
1108
1079
  plan,
1109
- hunks=tuple(
1110
- dataclasses.replace(hunk, status="would_apply")
1111
- for hunk in plan.hunks
1112
- ),
1080
+ hunks=tuple(dataclasses.replace(hunk, status="would_apply") for hunk in plan.hunks),
1113
1081
  )
1114
1082
  for plan in plans
1115
1083
  ]
@@ -1123,7 +1091,7 @@ def apply_patch(
1123
1091
 
1124
1092
  file_results = tuple(
1125
1093
  PatchFileResult(
1126
- path=plan.relative,
1094
+ path=reported_path(handle.root, plan.relative),
1127
1095
  change=plan.change,
1128
1096
  applied=True,
1129
1097
  before_hash=plan.before_hash,
@@ -1134,7 +1102,7 @@ def apply_patch(
1134
1102
  )
1135
1103
  item_results = tuple(
1136
1104
  OperationItemResult(
1137
- path=plan.relative,
1105
+ path=reported_path(handle.root, plan.relative),
1138
1106
  change=plan.change,
1139
1107
  before_hash=plan.before_hash,
1140
1108
  after_hash=plan.after_hash,
@@ -1147,7 +1115,7 @@ def apply_patch(
1147
1115
  return as_dry_run(
1148
1116
  MutationResult(
1149
1117
  workspace=handle.name,
1150
- path=primary.relative,
1118
+ path=reported_path(handle.root, primary.relative),
1151
1119
  changed=True,
1152
1120
  before_hash=primary.before_hash,
1153
1121
  after_hash=primary.after_hash,
@@ -1240,7 +1208,7 @@ def apply_patch(
1240
1208
 
1241
1209
  return MutationResult(
1242
1210
  workspace=handle.name,
1243
- path=primary.relative,
1211
+ path=reported_path(handle.root, primary.relative),
1244
1212
  changed=True,
1245
1213
  before_hash=primary.before_hash,
1246
1214
  after_hash=primary.after_hash,
@@ -28,7 +28,7 @@ from ..models import (
28
28
  PathInfo,
29
29
  ReadManyEntry,
30
30
  )
31
- from ..paths import resolve_path
31
+ from ..paths import reported_path, resolve_path
32
32
  from ..safety.hashing import hash_file
33
33
  from ..safety.textdata import (
34
34
  BINARY_SAMPLE_SIZE,
@@ -136,7 +136,7 @@ def read_file(
136
136
  next_start_line = last + 1 if truncated and last == loaded_line_count else None
137
137
 
138
138
  return FileReadResult(
139
- path=resolved.relative,
139
+ path=resolved.reported,
140
140
  content=content,
141
141
  start_line=first,
142
142
  end_line=last,
@@ -265,7 +265,7 @@ def list_directory(
265
265
  raise storage_failure("inspect", relative, exc) from exc
266
266
  entries.append(
267
267
  DirectoryEntry(
268
- path=relative,
268
+ path=reported_path(handle.root, relative),
269
269
  name=child.name,
270
270
  kind=kind,
271
271
  size_bytes=size_bytes,
@@ -277,7 +277,7 @@ def list_directory(
277
277
 
278
278
  add(base, 0)
279
279
  return DirectoryListing(
280
- path=resolved.relative or ".",
280
+ path=resolved.reported,
281
281
  entries=tuple(entries),
282
282
  truncated=truncated,
283
283
  max_entries=limit,
@@ -305,7 +305,7 @@ def get_file_info(handle: WorkspaceHandle, path: str) -> PathInfo:
305
305
  kind = "other"
306
306
 
307
307
  base = PathInfo(
308
- path=resolved.relative,
308
+ path=resolved.reported,
309
309
  kind=kind,
310
310
  size_bytes=info.st_size,
311
311
  mode=info.st_mode & 0o7777,
@@ -366,5 +366,5 @@ def get_file_hash(handle: WorkspaceHandle, path: str) -> str:
366
366
  def file_exists(handle: WorkspaceHandle, path: str) -> ExistsResult:
367
367
  resolved = resolve_path(handle.root, path, state_dir=handle.state_dir)
368
368
  if not resolved.exists:
369
- return ExistsResult(path=resolved.relative, exists=False)
370
- return ExistsResult(path=resolved.relative, exists=True, kind=resolved.kind)
369
+ return ExistsResult(path=resolved.reported, exists=False)
370
+ return ExistsResult(path=resolved.reported, exists=True, kind=resolved.kind)
@@ -15,7 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from ..errors import FilekeeperError, InvalidPatternError, ValidationError, storage_failure
17
17
  from ..models import ContentMatch, ContentSearchResult, PathSearchResult
18
- from ..paths import resolve_path
18
+ from ..paths import reported_path, resolve_path
19
19
  from ..safety.textdata import decode_bytes, looks_binary
20
20
  from ..workspace import WorkspaceHandle
21
21
 
@@ -94,10 +94,10 @@ def search_files(
94
94
  break
95
95
  relative = _relative(handle, candidate)
96
96
  if _matches_glob(relative, candidate.name, glob):
97
- matches.append(relative)
97
+ matches.append(reported_path(handle.root, relative))
98
98
 
99
99
  return PathSearchResult(
100
- path=resolved.relative or ".",
100
+ path=resolved.reported,
101
101
  glob=glob,
102
102
  paths=tuple(matches),
103
103
  truncated=truncated,
@@ -179,7 +179,7 @@ def search_content(
179
179
  column = index + 1
180
180
  matches.append(
181
181
  ContentMatch(
182
- path=relative,
182
+ path=reported_path(handle.root, relative),
183
183
  line_number=number,
184
184
  line=line,
185
185
  column=column,
@@ -189,7 +189,7 @@ def search_content(
189
189
  break
190
190
 
191
191
  return ContentSearchResult(
192
- path=resolved.relative or ".",
192
+ path=resolved.reported,
193
193
  query=query,
194
194
  regex=regex,
195
195
  matches=tuple(matches),
@@ -39,7 +39,7 @@ from ..errors import (
39
39
  )
40
40
  from ..history.journal import OperationJournal, PendingItem
41
41
  from ..models import MutationResult, OperationItemResult, as_dry_run
42
- from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
42
+ from ..paths import ResolvedPath, ensure_parent_directory, reported_path, resolve_path
43
43
  from ..safety.atomic import atomic_write_bytes, fsync_directory
44
44
  from ..safety.hashing import format_digest, hash_bytes, hash_file, hashes_match, normalize_hash
45
45
  from ..safety.sensitive import sensitive_reason
@@ -81,9 +81,7 @@ def _default_file_mode() -> int:
81
81
  return 0o666 & ~current
82
82
 
83
83
 
84
- def _snapshot(
85
- path: Path, cap: int, *, display_path: str
86
- ) -> tuple[str | None, bytes | None, bool]:
84
+ def _snapshot(path: Path, cap: int, *, display_path: str) -> tuple[str | None, bytes | None, bool]:
87
85
  """Return ``(hash, bytes, exists)`` for a file, without reading oversize content.
88
86
 
89
87
  ``bytes`` is ``None`` for an oversize file: the hash is still recorded, the
@@ -237,7 +235,7 @@ def _after_write(
237
235
 
238
236
  result = MutationResult(
239
237
  workspace=handle.name,
240
- path=resolved.relative,
238
+ path=resolved.reported,
241
239
  changed=True,
242
240
  before_hash=before_hash,
243
241
  after_hash=after_hash,
@@ -250,7 +248,7 @@ def _after_write(
250
248
  warnings=warnings,
251
249
  items=(
252
250
  OperationItemResult(
253
- path=resolved.relative,
251
+ path=resolved.reported,
254
252
  change=change, # type: ignore[arg-type]
255
253
  before_hash=before_hash,
256
254
  after_hash=after_hash,
@@ -289,8 +287,7 @@ def create_file(
289
287
  raise NotAFileError("path is a directory", details={"path": resolved.relative})
290
288
  if resolved.exists and expected_absent:
291
289
  raise AlreadyExistsError(
292
- "file already exists; "
293
- f"{CREATE_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
290
+ f"file already exists; {CREATE_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
294
291
  details={"path": resolved.relative},
295
292
  override_hint=CREATE_OVERWRITE_HINT,
296
293
  )
@@ -448,8 +445,7 @@ def copy_file(
448
445
  )
449
446
  if expected_destination_hash is None:
450
447
  raise AlreadyExistsError(
451
- "destination already exists; "
452
- f"{COPY_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
448
+ f"destination already exists; {COPY_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
453
449
  details={"path": destination_resolved.relative},
454
450
  override_hint=COPY_OVERWRITE_HINT,
455
451
  )
@@ -495,12 +491,10 @@ def copy_file(
495
491
  actor=actor,
496
492
  task_id=task_id,
497
493
  metadata={"source_path": source_resolved.relative},
498
- journal_summary=(
499
- f"Copied {source_resolved.relative} to {destination_resolved.relative}."
500
- ),
494
+ journal_summary=(f"Copied {source_resolved.relative} to {destination_resolved.relative}."),
501
495
  )
502
496
  item = OperationItemResult(
503
- path=source_resolved.relative,
497
+ path=reported_path(handle.root, source_resolved.relative),
504
498
  change="unchanged",
505
499
  before_hash=hash_bytes(data),
506
500
  after_hash=hash_bytes(data),
@@ -584,8 +578,7 @@ def move_file(
584
578
  )
585
579
  if destination_resolved.exists and not overwrite:
586
580
  raise AlreadyExistsError(
587
- "destination already exists; "
588
- f"{MOVE_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
581
+ f"destination already exists; {MOVE_OVERWRITE_HINT.sentence(CallerSurface.CORE)}",
589
582
  details={"path": destination_resolved.relative},
590
583
  override_hint=MOVE_OVERWRITE_HINT,
591
584
  )
@@ -608,9 +601,7 @@ def move_file(
608
601
  begun = journal.begin(
609
602
  operation_type="move_file",
610
603
  target_path=destination_resolved.relative,
611
- summary=(
612
- f"Moved {source_resolved.relative} to {destination_resolved.relative}."
613
- ),
604
+ summary=(f"Moved {source_resolved.relative} to {destination_resolved.relative}."),
614
605
  items=[
615
606
  PendingItem(
616
607
  relative_path=source_resolved.relative,
@@ -677,7 +668,7 @@ def move_file(
677
668
 
678
669
  result = MutationResult(
679
670
  workspace=handle.name,
680
- path=destination_resolved.relative,
671
+ path=destination_resolved.reported,
681
672
  changed=True,
682
673
  before_hash=source_hash,
683
674
  after_hash=after_hash,
@@ -691,13 +682,13 @@ def move_file(
691
682
  warnings=warnings,
692
683
  items=(
693
684
  OperationItemResult(
694
- path=source_resolved.relative,
685
+ path=source_resolved.reported,
695
686
  change="moved",
696
687
  before_hash=source_hash,
697
688
  after_hash=None,
698
689
  ),
699
690
  OperationItemResult(
700
- path=destination_resolved.relative,
691
+ path=destination_resolved.reported,
701
692
  change="created",
702
693
  before_hash=destination_before,
703
694
  after_hash=after_hash,
@@ -787,8 +778,7 @@ def delete_file(
787
778
  if journal is not None and operation_id is not None:
788
779
  journal.abort(operation_id)
789
780
  raise StorageError(
790
- f"cannot delete file {resolved.relative}: "
791
- f"{exc.strerror or type(exc).__name__}",
781
+ f"cannot delete file {resolved.relative}: {exc.strerror or type(exc).__name__}",
792
782
  details={"path": resolved.relative},
793
783
  ) from exc
794
784
  fsync_directory(resolved.absolute.parent)
@@ -797,7 +787,7 @@ def delete_file(
797
787
 
798
788
  result = MutationResult(
799
789
  workspace=handle.name,
800
- path=resolved.relative,
790
+ path=resolved.reported,
801
791
  changed=True,
802
792
  before_hash=before_hash,
803
793
  after_hash=None,
@@ -807,7 +797,7 @@ def delete_file(
807
797
  dry_run=dry_run,
808
798
  items=(
809
799
  OperationItemResult(
810
- path=resolved.relative,
800
+ path=resolved.reported,
811
801
  change="deleted",
812
802
  before_hash=before_hash,
813
803
  after_hash=None,
@@ -47,6 +47,21 @@ from .errors import (
47
47
  PathKind = Literal["file", "directory", "symlink", "other"]
48
48
 
49
49
 
50
+ def reported_path(root: Path, relative: str) -> str:
51
+ """Render a workspace-relative path for a caller.
52
+
53
+ A relative path is the normal form and is returned byte-for-byte. The one
54
+ exception is a workspace whose root is the filesystem root: there the
55
+ relative form cannot be told apart from a repository-relative path, so the
56
+ leading separator is restored and the reported string is the absolute path.
57
+ The empty relative path is rendered as the root itself.
58
+ """
59
+
60
+ if Path(root) == Path("/"):
61
+ return "/" + relative
62
+ return relative or "."
63
+
64
+
50
65
  @dataclass(frozen=True, slots=True)
51
66
  class ResolvedPath:
52
67
  """A path proven to be inside the workspace.
@@ -55,14 +70,22 @@ class ResolvedPath:
55
70
  operations act on. ``link_path`` is the path as located in the workspace,
56
71
  which differs only when the final component is a symlink; metadata calls need
57
72
  it to stat the link itself instead of its target.
73
+
74
+ ``reported`` is the string a caller sees; ``relative`` stays the form
75
+ operations and the journal use.
58
76
  """
59
77
 
78
+ root: Path
60
79
  absolute: Path
61
80
  relative: str
62
81
  exists: bool
63
82
  is_symlink: bool
64
83
  link_path: Path
65
84
 
85
+ @property
86
+ def reported(self) -> str:
87
+ return reported_path(self.root, self.relative)
88
+
66
89
  @property
67
90
  def kind(self) -> PathKind:
68
91
  if self.is_symlink:
@@ -202,6 +225,7 @@ def resolve_path(
202
225
  reported = link_location if is_link else canonical
203
226
  relative = reported.relative_to(root_canonical).as_posix()
204
227
  resolved = ResolvedPath(
228
+ root=root_canonical,
205
229
  absolute=canonical,
206
230
  relative=relative if relative != "." else "",
207
231
  exists=exists and canonical.exists(),
@@ -231,9 +231,7 @@ class FilekeeperService:
231
231
  if self.registered and self.handle.workspace_id is not None:
232
232
  workspace = self.owner.registry.get(self.handle.name)
233
233
  return self.owner.registry.ensure_state_dir(workspace)
234
- return ensure_state_directory(
235
- self.handle.state_dir, root=self.root, home=self.owner.home
236
- )
234
+ return ensure_state_directory(self.handle.state_dir, root=self.root, home=self.owner.home)
237
235
 
238
236
  def blob_store(self) -> BlobStore:
239
237
  return BlobStore(self.state_database_path().parent / "blobs", fsync=self.config.fsync)
@@ -69,9 +69,7 @@ def global_workspace_state_dir(workspace_id: str, home: Path) -> Path:
69
69
  return Path(home) / "workspaces" / workspace_id
70
70
 
71
71
 
72
- def display_state_location(
73
- state_dir: Path, *, root: Path | None, home: Path | None
74
- ) -> str:
72
+ def display_state_location(state_dir: Path, *, root: Path | None, home: Path | None) -> str:
75
73
  """Render a state directory without naming an absolute host path.
76
74
 
77
75
  A public error envelope is read by a caller confined to the workspace, so a
@@ -123,9 +121,7 @@ def resolve_state_location(workspace: Workspace, config: Config, home: Path) ->
123
121
  if config.state_location_explicit:
124
122
  # The operator asked for project-local state; do not redirect it.
125
123
  return StateLocation(project_local)
126
- return StateLocation(
127
- global_workspace_state_dir(workspace.id, home), GLOBAL_FALLBACK_WARNING
128
- )
124
+ return StateLocation(global_workspace_state_dir(workspace.id, home), GLOBAL_FALLBACK_WARNING)
129
125
 
130
126
 
131
127
  def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
@@ -434,10 +430,12 @@ class WorkspaceRegistry:
434
430
 
435
431
 
436
432
  def _row_to_workspace(row: sqlite3.Row) -> Workspace:
433
+ root_path = str(row["root_path"])
437
434
  return Workspace(
438
435
  id=str(row["id"]),
439
436
  name=str(row["name"]),
440
- root_path=str(row["root_path"]),
437
+ root_path=root_path,
441
438
  created_at=str(row["created_at"]),
442
439
  updated_at=str(row["updated_at"]),
440
+ root_exists=Path(root_path).is_dir(),
443
441
  )
File without changes