filekeeper-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filekeeper/__init__.py +35 -0
- filekeeper/cli/__init__.py +5 -0
- filekeeper/cli/app.py +866 -0
- filekeeper/cli/exit_codes.py +84 -0
- filekeeper/cli/output.py +244 -0
- filekeeper/core/__init__.py +51 -0
- filekeeper/core/config.py +133 -0
- filekeeper/core/database.py +226 -0
- filekeeper/core/diffing.py +127 -0
- filekeeper/core/errors.py +278 -0
- filekeeper/core/history/__init__.py +20 -0
- filekeeper/core/history/blobs.py +92 -0
- filekeeper/core/history/journal.py +430 -0
- filekeeper/core/history/repository.py +152 -0
- filekeeper/core/history/undo.py +358 -0
- filekeeper/core/models.py +293 -0
- filekeeper/core/operations/__init__.py +9 -0
- filekeeper/core/operations/directory.py +475 -0
- filekeeper/core/operations/edit.py +1239 -0
- filekeeper/core/operations/read.py +370 -0
- filekeeper/core/operations/search.py +198 -0
- filekeeper/core/operations/write.py +763 -0
- filekeeper/core/paths.py +236 -0
- filekeeper/core/safety/__init__.py +5 -0
- filekeeper/core/safety/atomic.py +129 -0
- filekeeper/core/safety/hashing.py +73 -0
- filekeeper/core/safety/sensitive.py +65 -0
- filekeeper/core/safety/textdata.py +142 -0
- filekeeper/core/safety/vcs.py +51 -0
- filekeeper/core/service.py +706 -0
- filekeeper/core/workspace.py +300 -0
- filekeeper_cli-0.1.0.dist-info/METADATA +498 -0
- filekeeper_cli-0.1.0.dist-info/RECORD +36 -0
- filekeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- filekeeper_cli-0.1.0.dist-info/entry_points.txt +3 -0
- filekeeper_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Directory creation, moving and deletion.
|
|
2
|
+
|
|
3
|
+
Deleting a tree never happens without both ``recursive`` and
|
|
4
|
+
``confirm_recursive``: the confirmation is a separate, explicit argument rather
|
|
5
|
+
than an inference from "it had children". With history in place the deleted
|
|
6
|
+
bytes are recoverable through ``keeper history undo``, but the removal itself is
|
|
7
|
+
still a sequence of filesystem calls and is never atomic across the tree.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..errors import (
|
|
17
|
+
AlreadyExistsError,
|
|
18
|
+
FilekeeperError,
|
|
19
|
+
PathNotFoundError,
|
|
20
|
+
StorageError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
storage_failure,
|
|
23
|
+
)
|
|
24
|
+
from ..errors import (
|
|
25
|
+
NotADirectoryError as FilekeeperNotADirectoryError,
|
|
26
|
+
)
|
|
27
|
+
from ..history.journal import OperationJournal, PendingItem
|
|
28
|
+
from ..models import MutationResult, OperationItemResult
|
|
29
|
+
from ..paths import ensure_parent_directory, resolve_path
|
|
30
|
+
from ..safety.atomic import fsync_directory
|
|
31
|
+
from ..safety.hashing import hash_file
|
|
32
|
+
from ..workspace import WorkspaceHandle
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _mode(path: Path) -> int | None:
|
|
36
|
+
try:
|
|
37
|
+
return stat.S_IMODE(path.stat().st_mode)
|
|
38
|
+
except OSError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _walk_tree(root: Path) -> list[Path]:
|
|
43
|
+
"""Every directory and file under ``root``, parents before children."""
|
|
44
|
+
|
|
45
|
+
found: list[Path] = [root]
|
|
46
|
+
for current, directories, files in os.walk(root, followlinks=False):
|
|
47
|
+
base = Path(current)
|
|
48
|
+
for name in sorted(directories):
|
|
49
|
+
found.append(base / name)
|
|
50
|
+
for name in sorted(files):
|
|
51
|
+
found.append(base / name)
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _file_bytes(
|
|
56
|
+
path: Path, cap: int, *, display_path: str
|
|
57
|
+
) -> tuple[str | None, bytes | None]:
|
|
58
|
+
try:
|
|
59
|
+
size = path.stat().st_size
|
|
60
|
+
except OSError:
|
|
61
|
+
return None, None
|
|
62
|
+
if size > cap:
|
|
63
|
+
try:
|
|
64
|
+
return hash_file(path, display_path=display_path), None
|
|
65
|
+
except (OSError, FilekeeperError):
|
|
66
|
+
return None, None
|
|
67
|
+
try:
|
|
68
|
+
data = path.read_bytes()
|
|
69
|
+
except OSError:
|
|
70
|
+
return None, None
|
|
71
|
+
from ..safety.hashing import hash_bytes
|
|
72
|
+
|
|
73
|
+
return hash_bytes(data), data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _remove_file(path: Path) -> None:
|
|
77
|
+
"""Remove one file. A separate function so failure injection has a seam."""
|
|
78
|
+
|
|
79
|
+
path.unlink()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_directory(
|
|
83
|
+
handle: WorkspaceHandle,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
parents: bool = False,
|
|
87
|
+
dry_run: bool = False,
|
|
88
|
+
journal: OperationJournal | None = None,
|
|
89
|
+
actor: str | None = None,
|
|
90
|
+
task_id: str | None = None,
|
|
91
|
+
) -> MutationResult:
|
|
92
|
+
"""Create a directory. ``parents`` also creates missing ancestors."""
|
|
93
|
+
|
|
94
|
+
resolved = resolve_path(handle.root, path, for_write=True, state_dir=handle.state_dir)
|
|
95
|
+
if resolved.exists:
|
|
96
|
+
raise AlreadyExistsError("path already exists", details={"path": resolved.relative})
|
|
97
|
+
target = resolved.absolute
|
|
98
|
+
if not parents and not target.parent.is_dir():
|
|
99
|
+
raise ValidationError(
|
|
100
|
+
"parent directory does not exist; pass parents=True to create ancestors",
|
|
101
|
+
details={"path": resolved.relative},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
created: list[Path] = [target]
|
|
105
|
+
if parents:
|
|
106
|
+
missing: list[Path] = []
|
|
107
|
+
candidate = target.parent
|
|
108
|
+
while candidate != handle.root and not candidate.exists():
|
|
109
|
+
missing.append(candidate)
|
|
110
|
+
candidate = candidate.parent
|
|
111
|
+
created = [*reversed(missing), target]
|
|
112
|
+
|
|
113
|
+
operation_id: str | None = None
|
|
114
|
+
if not dry_run:
|
|
115
|
+
if journal is not None:
|
|
116
|
+
items = [
|
|
117
|
+
PendingItem(
|
|
118
|
+
relative_path=directory.relative_to(handle.root).as_posix(),
|
|
119
|
+
item_type="directory",
|
|
120
|
+
before_exists=False,
|
|
121
|
+
after_exists=True,
|
|
122
|
+
after_mode=0o777 & ~_umask(),
|
|
123
|
+
)
|
|
124
|
+
for directory in created
|
|
125
|
+
]
|
|
126
|
+
begun = journal.begin(
|
|
127
|
+
operation_type="create_directory",
|
|
128
|
+
target_path=resolved.relative,
|
|
129
|
+
summary=f"Created directory {resolved.relative}.",
|
|
130
|
+
items=items,
|
|
131
|
+
actor=actor,
|
|
132
|
+
task_id=task_id,
|
|
133
|
+
)
|
|
134
|
+
operation_id = begun.operation_id
|
|
135
|
+
try:
|
|
136
|
+
if parents:
|
|
137
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
else:
|
|
139
|
+
target.mkdir()
|
|
140
|
+
except FileExistsError as exc:
|
|
141
|
+
raise AlreadyExistsError(
|
|
142
|
+
"path already exists", details={"path": resolved.relative}
|
|
143
|
+
) from exc
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
if journal is not None and operation_id is not None:
|
|
146
|
+
journal.abort(operation_id)
|
|
147
|
+
raise storage_failure("create directory", resolved.relative, exc) from exc
|
|
148
|
+
fsync_directory(target.parent)
|
|
149
|
+
if parents:
|
|
150
|
+
fsync_directory(target)
|
|
151
|
+
if journal is not None and operation_id is not None:
|
|
152
|
+
journal.commit(operation_id)
|
|
153
|
+
|
|
154
|
+
return MutationResult(
|
|
155
|
+
workspace=handle.name,
|
|
156
|
+
path=resolved.relative,
|
|
157
|
+
changed=True,
|
|
158
|
+
before_hash=None,
|
|
159
|
+
after_hash=None,
|
|
160
|
+
summary=f"{'Would create' if dry_run else 'Created'} directory {resolved.relative}.",
|
|
161
|
+
operation_id=operation_id,
|
|
162
|
+
undo_available=not dry_run and operation_id is not None,
|
|
163
|
+
dry_run=dry_run,
|
|
164
|
+
items=(OperationItemResult(path=resolved.relative, change="created"),),
|
|
165
|
+
affected_count=1,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _umask() -> int:
|
|
170
|
+
current = os.umask(0)
|
|
171
|
+
os.umask(current)
|
|
172
|
+
return current
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def move_directory(
|
|
176
|
+
handle: WorkspaceHandle,
|
|
177
|
+
source: str,
|
|
178
|
+
destination: str,
|
|
179
|
+
*,
|
|
180
|
+
dry_run: bool = False,
|
|
181
|
+
journal: OperationJournal | None = None,
|
|
182
|
+
actor: str | None = None,
|
|
183
|
+
task_id: str | None = None,
|
|
184
|
+
) -> MutationResult:
|
|
185
|
+
"""Rename a directory inside the workspace. Cross-device moves are refused."""
|
|
186
|
+
|
|
187
|
+
source_resolved = resolve_path(
|
|
188
|
+
handle.root,
|
|
189
|
+
source,
|
|
190
|
+
must_exist=True,
|
|
191
|
+
expect="directory",
|
|
192
|
+
for_write=True,
|
|
193
|
+
state_dir=handle.state_dir,
|
|
194
|
+
)
|
|
195
|
+
if not source_resolved.absolute.is_dir():
|
|
196
|
+
raise FilekeeperNotADirectoryError(
|
|
197
|
+
"source is not a directory", details={"path": source_resolved.relative}
|
|
198
|
+
)
|
|
199
|
+
if source_resolved.relative == "":
|
|
200
|
+
raise ValidationError("refusing to move the workspace root")
|
|
201
|
+
|
|
202
|
+
destination_resolved = resolve_path(
|
|
203
|
+
handle.root, destination, for_write=True, state_dir=handle.state_dir
|
|
204
|
+
)
|
|
205
|
+
if destination_resolved.exists:
|
|
206
|
+
raise AlreadyExistsError(
|
|
207
|
+
"destination already exists", details={"path": destination_resolved.relative}
|
|
208
|
+
)
|
|
209
|
+
ensure_parent_directory(destination_resolved)
|
|
210
|
+
|
|
211
|
+
source_path = source_resolved.absolute
|
|
212
|
+
destination_path = destination_resolved.absolute
|
|
213
|
+
if destination_path == source_path or source_path in destination_path.parents:
|
|
214
|
+
raise ValidationError(
|
|
215
|
+
"cannot move a directory into itself",
|
|
216
|
+
details={
|
|
217
|
+
"source": source_resolved.relative,
|
|
218
|
+
"destination": destination_resolved.relative,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
operation_id: str | None = None
|
|
223
|
+
skipped: list[str] = []
|
|
224
|
+
if not dry_run and journal is not None:
|
|
225
|
+
cap = handle.config.max_history_blob_bytes
|
|
226
|
+
items: list[PendingItem] = []
|
|
227
|
+
for entry in _walk_tree(source_path):
|
|
228
|
+
relative = entry.relative_to(handle.root).as_posix()
|
|
229
|
+
suffix = entry.relative_to(source_path).as_posix()
|
|
230
|
+
moved_relative = (
|
|
231
|
+
destination_resolved.relative
|
|
232
|
+
if suffix == "."
|
|
233
|
+
else f"{destination_resolved.relative}/{suffix}"
|
|
234
|
+
)
|
|
235
|
+
if entry.is_dir():
|
|
236
|
+
items.append(
|
|
237
|
+
PendingItem(
|
|
238
|
+
relative_path=relative,
|
|
239
|
+
item_type="directory",
|
|
240
|
+
before_exists=True,
|
|
241
|
+
after_exists=False,
|
|
242
|
+
before_mode=_mode(entry),
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
items.append(
|
|
246
|
+
PendingItem(
|
|
247
|
+
relative_path=moved_relative,
|
|
248
|
+
item_type="directory",
|
|
249
|
+
before_exists=False,
|
|
250
|
+
after_exists=True,
|
|
251
|
+
after_mode=_mode(entry),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
digest, data = _file_bytes(entry, cap, display_path=relative)
|
|
256
|
+
if data is None:
|
|
257
|
+
skipped.append(relative)
|
|
258
|
+
items.append(
|
|
259
|
+
PendingItem(
|
|
260
|
+
relative_path=relative,
|
|
261
|
+
item_type="file",
|
|
262
|
+
before_exists=True,
|
|
263
|
+
after_exists=False,
|
|
264
|
+
before_bytes=data,
|
|
265
|
+
before_hash=digest,
|
|
266
|
+
before_mode=_mode(entry),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
items.append(
|
|
270
|
+
PendingItem(
|
|
271
|
+
relative_path=moved_relative,
|
|
272
|
+
item_type="file",
|
|
273
|
+
before_exists=False,
|
|
274
|
+
after_exists=True,
|
|
275
|
+
after_bytes=data,
|
|
276
|
+
after_hash=digest,
|
|
277
|
+
after_mode=_mode(entry),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
begun = journal.begin(
|
|
281
|
+
operation_type="move_directory",
|
|
282
|
+
target_path=destination_resolved.relative,
|
|
283
|
+
summary=(
|
|
284
|
+
f"Moved directory {source_resolved.relative} "
|
|
285
|
+
f"to {destination_resolved.relative}."
|
|
286
|
+
),
|
|
287
|
+
items=items,
|
|
288
|
+
actor=actor,
|
|
289
|
+
task_id=task_id,
|
|
290
|
+
)
|
|
291
|
+
operation_id = begun.operation_id
|
|
292
|
+
skipped = list(begun.skipped)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
if not dry_run:
|
|
296
|
+
os.replace(source_path, destination_path)
|
|
297
|
+
fsync_directory(source_path.parent)
|
|
298
|
+
fsync_directory(destination_path.parent)
|
|
299
|
+
if journal is not None and operation_id is not None:
|
|
300
|
+
journal.commit(operation_id)
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
if journal is not None and operation_id is not None:
|
|
303
|
+
journal.abort(operation_id)
|
|
304
|
+
raise StorageError(
|
|
305
|
+
f"cannot move directory: {exc.strerror or type(exc).__name__}; moving across "
|
|
306
|
+
"filesystems is not supported",
|
|
307
|
+
details={
|
|
308
|
+
"source": source_resolved.relative,
|
|
309
|
+
"destination": destination_resolved.relative,
|
|
310
|
+
},
|
|
311
|
+
) from exc
|
|
312
|
+
|
|
313
|
+
return MutationResult(
|
|
314
|
+
workspace=handle.name,
|
|
315
|
+
path=destination_resolved.relative,
|
|
316
|
+
changed=True,
|
|
317
|
+
before_hash=None,
|
|
318
|
+
after_hash=None,
|
|
319
|
+
summary=(
|
|
320
|
+
f"{'Would move' if dry_run else 'Moved'} directory "
|
|
321
|
+
f"{source_resolved.relative} to {destination_resolved.relative}."
|
|
322
|
+
),
|
|
323
|
+
operation_id=operation_id,
|
|
324
|
+
undo_available=not dry_run and operation_id is not None and not skipped,
|
|
325
|
+
dry_run=dry_run,
|
|
326
|
+
items=(
|
|
327
|
+
OperationItemResult(path=source_resolved.relative, change="moved"),
|
|
328
|
+
OperationItemResult(path=destination_resolved.relative, change="created"),
|
|
329
|
+
),
|
|
330
|
+
affected_count=1,
|
|
331
|
+
history_skipped=tuple(sorted(set(skipped))),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _count_entries(root: Path) -> int:
|
|
336
|
+
total = 0
|
|
337
|
+
for _, directories, files in os.walk(root, followlinks=False):
|
|
338
|
+
total += len(directories) + len(files)
|
|
339
|
+
return total
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def delete_directory(
|
|
343
|
+
handle: WorkspaceHandle,
|
|
344
|
+
path: str,
|
|
345
|
+
*,
|
|
346
|
+
recursive: bool = False,
|
|
347
|
+
confirm_recursive: bool = False,
|
|
348
|
+
dry_run: bool = False,
|
|
349
|
+
journal: OperationJournal | None = None,
|
|
350
|
+
actor: str | None = None,
|
|
351
|
+
task_id: str | None = None,
|
|
352
|
+
) -> MutationResult:
|
|
353
|
+
"""Delete an empty directory, or a whole tree with two explicit flags."""
|
|
354
|
+
|
|
355
|
+
resolved = resolve_path(
|
|
356
|
+
handle.root,
|
|
357
|
+
path,
|
|
358
|
+
must_exist=True,
|
|
359
|
+
expect="directory",
|
|
360
|
+
for_write=True,
|
|
361
|
+
state_dir=handle.state_dir,
|
|
362
|
+
)
|
|
363
|
+
target = resolved.absolute
|
|
364
|
+
if resolved.relative == "" or target == handle.root:
|
|
365
|
+
raise ValidationError("refusing to delete the workspace root")
|
|
366
|
+
|
|
367
|
+
if not target.is_dir():
|
|
368
|
+
raise PathNotFoundError("directory no longer exists", details={"path": resolved.relative})
|
|
369
|
+
|
|
370
|
+
entries = _count_entries(target)
|
|
371
|
+
if entries > 0 and not recursive:
|
|
372
|
+
raise ValidationError(
|
|
373
|
+
"directory is not empty; pass --recursive --confirm-recursive "
|
|
374
|
+
"to delete its contents",
|
|
375
|
+
details={"path": resolved.relative, "entries": entries},
|
|
376
|
+
)
|
|
377
|
+
if entries > 0 and not confirm_recursive:
|
|
378
|
+
raise ValidationError(
|
|
379
|
+
"recursive deletion requires explicit confirmation "
|
|
380
|
+
"(--recursive --confirm-recursive)",
|
|
381
|
+
details={"path": resolved.relative, "entries": entries},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
operation_id: str | None = None
|
|
385
|
+
warnings: tuple[str, ...] = ()
|
|
386
|
+
skipped: tuple[str, ...] = ()
|
|
387
|
+
if not dry_run and journal is not None:
|
|
388
|
+
cap = handle.config.max_history_blob_bytes
|
|
389
|
+
items: list[PendingItem] = []
|
|
390
|
+
for entry in _walk_tree(target):
|
|
391
|
+
relative = entry.relative_to(handle.root).as_posix()
|
|
392
|
+
if entry.is_dir():
|
|
393
|
+
items.append(
|
|
394
|
+
PendingItem(
|
|
395
|
+
relative_path=relative,
|
|
396
|
+
item_type="directory",
|
|
397
|
+
before_exists=True,
|
|
398
|
+
after_exists=False,
|
|
399
|
+
before_mode=_mode(entry),
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
digest, data = _file_bytes(entry, cap, display_path=relative)
|
|
404
|
+
items.append(
|
|
405
|
+
PendingItem(
|
|
406
|
+
relative_path=relative,
|
|
407
|
+
item_type="file",
|
|
408
|
+
before_exists=True,
|
|
409
|
+
after_exists=False,
|
|
410
|
+
before_bytes=data,
|
|
411
|
+
before_hash=digest,
|
|
412
|
+
before_mode=_mode(entry),
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
begun = journal.begin(
|
|
416
|
+
operation_type="delete_directory",
|
|
417
|
+
target_path=resolved.relative,
|
|
418
|
+
summary=f"Deleted directory {resolved.relative} ({entries} entries).",
|
|
419
|
+
items=items,
|
|
420
|
+
actor=actor,
|
|
421
|
+
task_id=task_id,
|
|
422
|
+
)
|
|
423
|
+
operation_id = begun.operation_id
|
|
424
|
+
skipped = begun.skipped
|
|
425
|
+
warnings = begun.warnings
|
|
426
|
+
|
|
427
|
+
if not dry_run:
|
|
428
|
+
try:
|
|
429
|
+
if recursive:
|
|
430
|
+
# Files first, deepest first, so a failure part-way leaves a
|
|
431
|
+
# truthful partial state that startup reconciliation can resolve.
|
|
432
|
+
for entry in sorted(
|
|
433
|
+
_walk_tree(target),
|
|
434
|
+
key=lambda item: len(item.parts),
|
|
435
|
+
reverse=True,
|
|
436
|
+
):
|
|
437
|
+
if entry.is_dir():
|
|
438
|
+
entry.rmdir()
|
|
439
|
+
else:
|
|
440
|
+
_remove_file(entry)
|
|
441
|
+
else:
|
|
442
|
+
target.rmdir()
|
|
443
|
+
except OSError as exc:
|
|
444
|
+
# Leave the operation pending: reconciliation inspects the actual
|
|
445
|
+
# state and reports the partial application honestly.
|
|
446
|
+
raise storage_failure("delete directory", resolved.relative, exc) from exc
|
|
447
|
+
fsync_directory(target.parent)
|
|
448
|
+
if journal is not None and operation_id is not None:
|
|
449
|
+
journal.commit(operation_id)
|
|
450
|
+
|
|
451
|
+
return MutationResult(
|
|
452
|
+
workspace=handle.name,
|
|
453
|
+
path=resolved.relative,
|
|
454
|
+
changed=True,
|
|
455
|
+
before_hash=None,
|
|
456
|
+
after_hash=None,
|
|
457
|
+
summary=(
|
|
458
|
+
f"{'Would delete' if dry_run else 'Deleted'} directory "
|
|
459
|
+
f"{resolved.relative} ({entries} entries)."
|
|
460
|
+
),
|
|
461
|
+
operation_id=operation_id,
|
|
462
|
+
undo_available=not dry_run and operation_id is not None and not skipped,
|
|
463
|
+
dry_run=dry_run,
|
|
464
|
+
items=(OperationItemResult(path=resolved.relative, change="deleted"),),
|
|
465
|
+
affected_count=entries,
|
|
466
|
+
warnings=(
|
|
467
|
+
*warnings,
|
|
468
|
+
"A directory tree is deleted as a sequence of filesystem calls: the "
|
|
469
|
+
"deletion is recoverable through history, but it is never atomic "
|
|
470
|
+
"across the tree.",
|
|
471
|
+
)
|
|
472
|
+
if entries > 0
|
|
473
|
+
else warnings,
|
|
474
|
+
history_skipped=skipped,
|
|
475
|
+
)
|