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.
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/PKG-INFO +11 -1
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/README.md +10 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/__init__.py +1 -1
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/app.py +13 -8
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/output.py +4 -3
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/config.py +1 -2
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/errors.py +1 -3
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/blobs.py +1 -3
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/journal.py +2 -6
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/repository.py +33 -5
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/undo.py +5 -8
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/models.py +1 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/directory.py +10 -14
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/edit.py +18 -50
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/read.py +7 -7
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/search.py +5 -5
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/write.py +16 -26
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/paths.py +24 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/service.py +1 -3
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/workspace.py +5 -7
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/LICENSE +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/__init__.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/database.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.7 → filekeeper_cli-0.1.8}/src/filekeeper/core/safety/textdata.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
"
|
|
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 =
|
|
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
|
|
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:
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
353
|
-
OperationItemResult(path=destination_resolved.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
370
|
-
return ExistsResult(path=resolved.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|