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.
@@ -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
+ )