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,763 @@
1
+ """Whole-file mutations.
2
+
3
+ Every write goes through :func:`atomic_write_bytes`, so each single-file
4
+ replacement is atomic: after a failure the file holds either its old bytes or
5
+ its new bytes, never a truncated mix.
6
+
7
+ Each mutation is also recorded in the durable history before the filesystem is
8
+ touched: blobs first, then the pending operation, then the atomic replacement,
9
+ then the commit mark. A failure before the replacement marks the operation
10
+ failed; a failure after it leaves the operation pending for startup
11
+ reconciliation, which never discards the recoverable copy.
12
+
13
+ Copy and move touch two paths. Each individual step is a complete atomic
14
+ replacement or rename, but the pair is **not** atomic across both paths: it is
15
+ recoverable through history, never all-or-nothing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import codecs
21
+ import os
22
+ import stat
23
+ from pathlib import Path
24
+
25
+ from ..errors import (
26
+ AlreadyExistsError,
27
+ ContentConflictError,
28
+ FilekeeperError,
29
+ LimitExceededError,
30
+ NotAFileError,
31
+ SensitivePathRefusedError,
32
+ StorageError,
33
+ SymlinkWriteRefusedError,
34
+ ValidationError,
35
+ storage_failure,
36
+ )
37
+ from ..history.journal import OperationJournal, PendingItem
38
+ from ..models import MutationResult, OperationItemResult
39
+ from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
40
+ from ..safety.atomic import atomic_write_bytes, fsync_directory
41
+ from ..safety.hashing import format_digest, hash_bytes, hash_file, hashes_match, normalize_hash
42
+ from ..safety.sensitive import sensitive_reason
43
+ from ..safety.textdata import decode_bytes, encode_text, looks_binary
44
+ from ..safety.vcs import ignore_warning
45
+ from ..workspace import WorkspaceHandle
46
+
47
+ SECRET_FLAG_HINT = (
48
+ "pass allow_sensitive_write=True in Core, or the CLI flag "
49
+ "--i-accept-secret-write-risk, if you really mean to write credential material"
50
+ )
51
+
52
+
53
+ def _default_file_mode() -> int:
54
+ current = os.umask(0)
55
+ os.umask(current)
56
+ return 0o666 & ~current
57
+
58
+
59
+ def _snapshot(
60
+ path: Path, cap: int, *, display_path: str
61
+ ) -> tuple[str | None, bytes | None, bool]:
62
+ """Return ``(hash, bytes, exists)`` for a file, without reading oversize content.
63
+
64
+ ``bytes`` is ``None`` for an oversize file: the hash is still recorded, the
65
+ content is not. That is what makes the history cap visible instead of silent.
66
+ """
67
+
68
+ try:
69
+ info = path.stat()
70
+ except FileNotFoundError:
71
+ return None, None, False
72
+ if info.st_size > cap:
73
+ return hash_file(path, display_path=display_path), None, True
74
+ try:
75
+ data = path.read_bytes()
76
+ except OSError as exc:
77
+ raise storage_failure("read", display_path, exc) from exc
78
+ return hash_bytes(data), data, True
79
+
80
+
81
+ def _check_size(handle: WorkspaceHandle, data: bytes, path: str) -> None:
82
+ if len(data) > handle.config.max_write_bytes:
83
+ raise LimitExceededError(
84
+ "content exceeds the configured write size limit",
85
+ limit=handle.config.max_write_bytes,
86
+ actual=len(data),
87
+ details={"path": path},
88
+ )
89
+
90
+
91
+ def _guard_sensitive(
92
+ handle: WorkspaceHandle, resolved: ResolvedPath, *, allow_sensitive_write: bool
93
+ ) -> tuple[str, ...]:
94
+ reason = sensitive_reason(resolved.relative)
95
+ if reason is not None and not allow_sensitive_write:
96
+ raise SensitivePathRefusedError(
97
+ f"refusing to write {resolved.relative}: it looks like credential or key "
98
+ f"material ({reason}); {SECRET_FLAG_HINT}",
99
+ details={"path": resolved.relative, "reason": reason},
100
+ )
101
+ warnings: list[str] = []
102
+ warning = ignore_warning(handle.root, resolved.absolute, resolved.relative)
103
+ if warning is not None:
104
+ warnings.append(warning)
105
+ if reason is not None:
106
+ warnings.append(f"sensitive-path refusal was overridden for {resolved.relative} ({reason})")
107
+ return tuple(warnings)
108
+
109
+
110
+ def _target_mode(resolved: ResolvedPath) -> int | None:
111
+ try:
112
+ return stat.S_IMODE(resolved.absolute.stat().st_mode)
113
+ except FileNotFoundError:
114
+ return None
115
+
116
+
117
+ def _encoding_for_existing(target: Path, *, encoding: str | None) -> tuple[str, bool]:
118
+ """Return ``(encoding, has_bom)`` for rewriting an existing file."""
119
+
120
+ try:
121
+ raw = target.read_bytes()
122
+ except OSError:
123
+ return (encoding or "utf-8", False)
124
+ try:
125
+ _, profile = decode_bytes(raw, encoding=encoding, path=str(target.name))
126
+ except FilekeeperError:
127
+ if encoding is None:
128
+ raise
129
+ return (encoding, encoding == "utf-8" and raw.startswith(codecs.BOM_UTF8))
130
+ return (profile.encoding, profile.has_bom)
131
+
132
+
133
+ def _after_write(
134
+ handle: WorkspaceHandle,
135
+ resolved: ResolvedPath,
136
+ data: bytes,
137
+ *,
138
+ operation_type: str,
139
+ change: str,
140
+ before_hash: str | None,
141
+ before_bytes: bytes | None,
142
+ before_exists: bool,
143
+ before_mode: int | None,
144
+ dry_run: bool,
145
+ warnings: tuple[str, ...],
146
+ past_verb: str,
147
+ dry_verb: str,
148
+ journal: OperationJournal | None,
149
+ actor: str | None,
150
+ task_id: str | None,
151
+ ) -> MutationResult:
152
+ after_hash = hash_bytes(data)
153
+ operation_id: str | None = None
154
+ skipped: tuple[str, ...] = ()
155
+ undo_available = False
156
+
157
+ if not dry_run:
158
+ pending = PendingItem(
159
+ relative_path=resolved.relative,
160
+ item_type="file",
161
+ before_exists=before_exists,
162
+ after_exists=True,
163
+ before_bytes=before_bytes,
164
+ after_bytes=data,
165
+ before_hash=before_hash,
166
+ after_hash=after_hash,
167
+ before_mode=before_mode,
168
+ after_mode=_target_mode(resolved) or _default_file_mode(),
169
+ )
170
+ if journal is not None:
171
+ begun = journal.begin(
172
+ operation_type=operation_type,
173
+ target_path=resolved.relative,
174
+ summary=f"{past_verb} {resolved.relative}.",
175
+ items=[pending],
176
+ actor=actor,
177
+ task_id=task_id,
178
+ before_hash=before_hash,
179
+ after_hash=after_hash,
180
+ )
181
+ operation_id = begun.operation_id
182
+ skipped = begun.skipped
183
+ warnings = (*warnings, *begun.warnings)
184
+ try:
185
+ after_hash = atomic_write_bytes(
186
+ resolved.absolute,
187
+ data,
188
+ mode=_target_mode(resolved),
189
+ fsync=handle.config.fsync,
190
+ display_path=resolved.relative,
191
+ )
192
+ except FilekeeperError:
193
+ if journal is not None and operation_id is not None:
194
+ journal.abort(operation_id)
195
+ raise
196
+ if journal is not None and operation_id is not None:
197
+ journal.commit(operation_id)
198
+ undo_available = not skipped
199
+
200
+ return MutationResult(
201
+ workspace=handle.name,
202
+ path=resolved.relative,
203
+ changed=True,
204
+ before_hash=before_hash,
205
+ after_hash=after_hash,
206
+ summary=(
207
+ f"{dry_verb} {resolved.relative}." if dry_run else f"{past_verb} {resolved.relative}."
208
+ ),
209
+ operation_id=operation_id,
210
+ undo_available=undo_available,
211
+ dry_run=dry_run,
212
+ warnings=warnings,
213
+ items=(
214
+ OperationItemResult(
215
+ path=resolved.relative,
216
+ change=change, # type: ignore[arg-type]
217
+ before_hash=before_hash,
218
+ after_hash=after_hash,
219
+ size_bytes=len(data),
220
+ ),
221
+ ),
222
+ affected_count=1,
223
+ history_skipped=skipped,
224
+ )
225
+
226
+
227
+ def create_file(
228
+ handle: WorkspaceHandle,
229
+ path: str,
230
+ content: str,
231
+ *,
232
+ expected_absent: bool = True,
233
+ encoding: str = "utf-8",
234
+ allow_sensitive_write: bool = False,
235
+ dry_run: bool = False,
236
+ journal: OperationJournal | None = None,
237
+ actor: str | None = None,
238
+ task_id: str | None = None,
239
+ ) -> MutationResult:
240
+ """Create a file. Refuses an existing target unless ``expected_absent`` is False."""
241
+
242
+ resolved = resolve_path(handle.root, path, for_write=True, state_dir=handle.state_dir)
243
+ if resolved.exists and resolved.absolute.is_dir():
244
+ raise NotAFileError("path is a directory", details={"path": resolved.relative})
245
+ if resolved.exists and expected_absent:
246
+ raise AlreadyExistsError(
247
+ "file already exists; pass expected_absent=False to overwrite",
248
+ details={"path": resolved.relative},
249
+ )
250
+ warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
251
+ ensure_parent_directory(resolved)
252
+
253
+ before_hash, before_bytes, before_exists = _snapshot(
254
+ resolved.absolute,
255
+ handle.config.max_history_blob_bytes,
256
+ display_path=resolved.relative,
257
+ )
258
+ before_mode = _target_mode(resolved) if before_exists else None
259
+ data = encode_text(content, encoding=encoding, has_bom=False)
260
+ _check_size(handle, data, resolved.relative)
261
+
262
+ return _after_write(
263
+ handle,
264
+ resolved,
265
+ data,
266
+ operation_type="create_file",
267
+ change="modified" if before_exists else "created",
268
+ before_hash=before_hash,
269
+ before_bytes=before_bytes,
270
+ before_exists=before_exists,
271
+ before_mode=before_mode,
272
+ dry_run=dry_run,
273
+ warnings=warnings,
274
+ past_verb="Created" if not before_exists else "Overwrote",
275
+ dry_verb="Would create" if not before_exists else "Would overwrite",
276
+ journal=journal,
277
+ actor=actor,
278
+ task_id=task_id,
279
+ )
280
+
281
+
282
+ def write_file(
283
+ handle: WorkspaceHandle,
284
+ path: str,
285
+ content: str,
286
+ *,
287
+ expected_hash: str | None = None,
288
+ encoding: str | None = None,
289
+ allow_sensitive_write: bool = False,
290
+ dry_run: bool = False,
291
+ journal: OperationJournal | None = None,
292
+ actor: str | None = None,
293
+ task_id: str | None = None,
294
+ ) -> MutationResult:
295
+ """Replace a file's complete content, optionally guarded by its current hash.
296
+
297
+ The existing file's encoding and byte-order mark are preserved unless an
298
+ encoding is named explicitly. Line endings inside ``content`` are written
299
+ exactly as given: they are never normalised.
300
+ """
301
+
302
+ resolved = resolve_path(handle.root, path, for_write=True, state_dir=handle.state_dir)
303
+ if resolved.exists and resolved.absolute.is_dir():
304
+ raise NotAFileError("path is a directory", details={"path": resolved.relative})
305
+ warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
306
+ ensure_parent_directory(resolved)
307
+
308
+ before_hash, before_bytes, before_exists = _snapshot(
309
+ resolved.absolute,
310
+ handle.config.max_history_blob_bytes,
311
+ display_path=resolved.relative,
312
+ )
313
+ before_mode = _target_mode(resolved) if before_exists else None
314
+ if expected_hash is not None:
315
+ wanted = normalize_hash(expected_hash)
316
+ if before_hash is None:
317
+ raise ContentConflictError(
318
+ "file does not exist, so it cannot match the expected hash",
319
+ details={"path": resolved.relative, "expected_hash": wanted},
320
+ )
321
+ if not hashes_match(wanted, before_hash):
322
+ raise ContentConflictError(
323
+ "file content changed since it was read; refusing to overwrite",
324
+ details={
325
+ "path": resolved.relative,
326
+ "expected_hash": wanted,
327
+ "actual_hash": before_hash,
328
+ },
329
+ )
330
+
331
+ if before_exists:
332
+ effective_encoding, has_bom = _encoding_for_existing(resolved.absolute, encoding=encoding)
333
+ else:
334
+ effective_encoding, has_bom = encoding or "utf-8", False
335
+
336
+ data = encode_text(content, encoding=effective_encoding, has_bom=has_bom)
337
+ _check_size(handle, data, resolved.relative)
338
+
339
+ return _after_write(
340
+ handle,
341
+ resolved,
342
+ data,
343
+ operation_type="write_file",
344
+ change="modified" if before_exists else "created",
345
+ before_hash=before_hash,
346
+ before_bytes=before_bytes,
347
+ before_exists=before_exists,
348
+ before_mode=before_mode,
349
+ dry_run=dry_run,
350
+ warnings=warnings,
351
+ past_verb="Wrote",
352
+ dry_verb="Would write",
353
+ journal=journal,
354
+ actor=actor,
355
+ task_id=task_id,
356
+ )
357
+
358
+
359
+ def copy_file(
360
+ handle: WorkspaceHandle,
361
+ source: str,
362
+ destination: str,
363
+ *,
364
+ expected_destination_hash: str | None = None,
365
+ allow_sensitive_write: bool = False,
366
+ dry_run: bool = False,
367
+ journal: OperationJournal | None = None,
368
+ actor: str | None = None,
369
+ task_id: str | None = None,
370
+ ) -> MutationResult:
371
+ """Copy a file's bytes to a new path. Never overwrites an unguarded destination."""
372
+
373
+ source_resolved = resolve_path(
374
+ handle.root,
375
+ source,
376
+ must_exist=True,
377
+ expect="file",
378
+ state_dir=handle.state_dir,
379
+ )
380
+ if source_resolved.absolute.is_dir():
381
+ raise NotAFileError("source is a directory", details={"path": source_resolved.relative})
382
+
383
+ destination_resolved = resolve_path(
384
+ handle.root, destination, for_write=True, state_dir=handle.state_dir
385
+ )
386
+ if destination_resolved.absolute == source_resolved.absolute:
387
+ raise ValidationError("source and destination are the same path")
388
+
389
+ destination_before: str | None = None
390
+ destination_bytes: bytes | None = None
391
+ destination_exists = False
392
+ if destination_resolved.exists:
393
+ if destination_resolved.absolute.is_dir():
394
+ raise NotAFileError(
395
+ "destination is a directory", details={"path": destination_resolved.relative}
396
+ )
397
+ destination_before, destination_bytes, destination_exists = _snapshot(
398
+ destination_resolved.absolute,
399
+ handle.config.max_history_blob_bytes,
400
+ display_path=destination_resolved.relative,
401
+ )
402
+ if expected_destination_hash is None:
403
+ raise AlreadyExistsError(
404
+ "destination already exists; pass expected_destination_hash to "
405
+ "replace it deliberately",
406
+ details={"path": destination_resolved.relative},
407
+ )
408
+ wanted = normalize_hash(expected_destination_hash)
409
+ if not hashes_match(wanted, destination_before or ""):
410
+ raise ContentConflictError(
411
+ "destination changed since it was read; refusing to overwrite",
412
+ details={
413
+ "path": destination_resolved.relative,
414
+ "expected_hash": wanted,
415
+ "actual_hash": destination_before,
416
+ },
417
+ )
418
+
419
+ warnings = _guard_sensitive(
420
+ handle,
421
+ destination_resolved,
422
+ allow_sensitive_write=allow_sensitive_write,
423
+ )
424
+ ensure_parent_directory(destination_resolved)
425
+
426
+ try:
427
+ data = source_resolved.absolute.read_bytes()
428
+ except OSError as exc:
429
+ raise storage_failure("read", source_resolved.relative, exc) from exc
430
+ _check_size(handle, data, destination_resolved.relative)
431
+
432
+ result = _after_write(
433
+ handle,
434
+ destination_resolved,
435
+ data,
436
+ operation_type="copy_file",
437
+ change="modified" if destination_exists else "created",
438
+ before_hash=destination_before,
439
+ before_bytes=destination_bytes,
440
+ before_exists=destination_exists,
441
+ before_mode=_target_mode(destination_resolved) if destination_exists else None,
442
+ dry_run=dry_run,
443
+ warnings=warnings,
444
+ past_verb="Copied",
445
+ dry_verb="Would copy",
446
+ journal=journal,
447
+ actor=actor,
448
+ task_id=task_id,
449
+ )
450
+ item = OperationItemResult(
451
+ path=source_resolved.relative,
452
+ change="unchanged",
453
+ before_hash=hash_bytes(data),
454
+ after_hash=hash_bytes(data),
455
+ size_bytes=len(data),
456
+ )
457
+ return MutationResult(
458
+ workspace=result.workspace,
459
+ path=result.path,
460
+ changed=True,
461
+ before_hash=result.before_hash,
462
+ after_hash=result.after_hash,
463
+ summary=(
464
+ f"{'Would copy' if dry_run else 'Copied'} {source_resolved.relative} "
465
+ f"to {destination_resolved.relative}."
466
+ ),
467
+ operation_id=result.operation_id,
468
+ undo_available=result.undo_available,
469
+ dry_run=dry_run,
470
+ warnings=result.warnings,
471
+ items=(*result.items, item),
472
+ affected_count=1,
473
+ history_skipped=result.history_skipped,
474
+ )
475
+
476
+
477
+ def move_file(
478
+ handle: WorkspaceHandle,
479
+ source: str,
480
+ destination: str,
481
+ *,
482
+ overwrite: bool = False,
483
+ allow_sensitive_write: bool = False,
484
+ dry_run: bool = False,
485
+ journal: OperationJournal | None = None,
486
+ actor: str | None = None,
487
+ task_id: str | None = None,
488
+ ) -> MutationResult:
489
+ """Rename a file. Refuses an existing destination unless ``overwrite`` is set."""
490
+
491
+ source_resolved = resolve_path(
492
+ handle.root,
493
+ source,
494
+ must_exist=True,
495
+ expect="file",
496
+ state_dir=handle.state_dir,
497
+ )
498
+ if source_resolved.absolute.is_dir():
499
+ raise ValidationError(
500
+ "source is a directory; use the directory move operation",
501
+ details={"path": source_resolved.relative},
502
+ )
503
+ source_hash = hash_file(source_resolved.absolute, display_path=source_resolved.relative)
504
+ source_mode = _target_mode(source_resolved)
505
+ cap = handle.config.max_history_blob_bytes
506
+ source_bytes: bytes | None
507
+ try:
508
+ source_bytes = (
509
+ source_resolved.absolute.read_bytes()
510
+ if source_resolved.absolute.stat().st_size <= cap
511
+ else None
512
+ )
513
+ except OSError as exc:
514
+ raise storage_failure("read", source_resolved.relative, exc) from exc
515
+
516
+ destination_resolved = resolve_path(
517
+ handle.root, destination, for_write=True, state_dir=handle.state_dir
518
+ )
519
+ if destination_resolved.absolute == source_resolved.absolute:
520
+ raise ValidationError("source and destination are the same path")
521
+ if destination_resolved.exists and destination_resolved.absolute.is_dir():
522
+ raise NotAFileError(
523
+ "destination is a directory", details={"path": destination_resolved.relative}
524
+ )
525
+ if destination_resolved.exists and not overwrite:
526
+ raise AlreadyExistsError(
527
+ "destination already exists; pass overwrite=True to replace it",
528
+ details={"path": destination_resolved.relative},
529
+ )
530
+ warnings = _guard_sensitive(
531
+ handle,
532
+ destination_resolved,
533
+ allow_sensitive_write=allow_sensitive_write,
534
+ )
535
+ ensure_parent_directory(destination_resolved)
536
+ destination_before, destination_bytes, destination_exists = _snapshot(
537
+ destination_resolved.absolute,
538
+ cap,
539
+ display_path=destination_resolved.relative,
540
+ )
541
+
542
+ operation_id: str | None = None
543
+ skipped: tuple[str, ...] = ()
544
+ if not dry_run:
545
+ if journal is not None:
546
+ begun = journal.begin(
547
+ operation_type="move_file",
548
+ target_path=destination_resolved.relative,
549
+ summary=(
550
+ f"Moved {source_resolved.relative} to {destination_resolved.relative}."
551
+ ),
552
+ items=[
553
+ PendingItem(
554
+ relative_path=source_resolved.relative,
555
+ item_type="file",
556
+ before_exists=True,
557
+ after_exists=False,
558
+ before_bytes=source_bytes,
559
+ before_hash=source_hash,
560
+ before_mode=source_mode,
561
+ ),
562
+ PendingItem(
563
+ relative_path=destination_resolved.relative,
564
+ item_type="file",
565
+ before_exists=destination_exists,
566
+ after_exists=True,
567
+ before_bytes=destination_bytes,
568
+ before_hash=destination_before,
569
+ after_bytes=source_bytes,
570
+ after_hash=source_hash,
571
+ before_mode=_target_mode(destination_resolved)
572
+ if destination_exists
573
+ else None,
574
+ after_mode=source_mode,
575
+ ),
576
+ ],
577
+ actor=actor,
578
+ task_id=task_id,
579
+ before_hash=destination_before,
580
+ after_hash=source_hash,
581
+ )
582
+ operation_id = begun.operation_id
583
+ skipped = begun.skipped
584
+ warnings = (*warnings, *begun.warnings)
585
+ try:
586
+ os.replace(source_resolved.absolute, destination_resolved.absolute)
587
+ except OSError as exc:
588
+ if journal is not None and operation_id is not None:
589
+ journal.abort(operation_id)
590
+ raise StorageError(
591
+ f"cannot move file: {exc.strerror or type(exc).__name__}; moving across "
592
+ "filesystems is not supported",
593
+ details={
594
+ "source": source_resolved.relative,
595
+ "destination": destination_resolved.relative,
596
+ },
597
+ ) from exc
598
+ fsync_directory(source_resolved.absolute.parent)
599
+ fsync_directory(destination_resolved.absolute.parent)
600
+ after_hash = hash_file(
601
+ destination_resolved.absolute, display_path=destination_resolved.relative
602
+ )
603
+ if after_hash != source_hash:
604
+ if journal is not None and operation_id is not None:
605
+ journal.abort(operation_id)
606
+ raise StorageError(
607
+ "moved file content does not match its source",
608
+ details={"path": destination_resolved.relative},
609
+ )
610
+ if journal is not None and operation_id is not None:
611
+ journal.commit(operation_id)
612
+ else:
613
+ after_hash = source_hash
614
+
615
+ return MutationResult(
616
+ workspace=handle.name,
617
+ path=destination_resolved.relative,
618
+ changed=True,
619
+ before_hash=source_hash,
620
+ after_hash=after_hash,
621
+ summary=(
622
+ f"{'Would move' if dry_run else 'Moved'} {source_resolved.relative} "
623
+ f"to {destination_resolved.relative}."
624
+ ),
625
+ operation_id=operation_id,
626
+ undo_available=not dry_run and operation_id is not None and not skipped,
627
+ dry_run=dry_run,
628
+ warnings=warnings,
629
+ items=(
630
+ OperationItemResult(
631
+ path=source_resolved.relative,
632
+ change="moved",
633
+ before_hash=source_hash,
634
+ after_hash=None,
635
+ ),
636
+ OperationItemResult(
637
+ path=destination_resolved.relative,
638
+ change="created",
639
+ before_hash=destination_before,
640
+ after_hash=after_hash,
641
+ ),
642
+ ),
643
+ affected_count=1,
644
+ history_skipped=skipped,
645
+ )
646
+
647
+
648
+ def delete_file(
649
+ handle: WorkspaceHandle,
650
+ path: str,
651
+ *,
652
+ expected_hash: str | None = None,
653
+ dry_run: bool = False,
654
+ journal: OperationJournal | None = None,
655
+ actor: str | None = None,
656
+ task_id: str | None = None,
657
+ ) -> MutationResult:
658
+ """Delete a file, optionally guarded by its current hash."""
659
+
660
+ resolved = resolve_path(
661
+ handle.root,
662
+ path,
663
+ must_exist=True,
664
+ expect="file",
665
+ state_dir=handle.state_dir,
666
+ )
667
+ if resolved.absolute.is_dir():
668
+ raise NotAFileError(
669
+ "path is a directory; use the directory delete operation",
670
+ details={"path": resolved.relative},
671
+ )
672
+ before_hash, before_bytes, _ = _snapshot(
673
+ resolved.absolute,
674
+ handle.config.max_history_blob_bytes,
675
+ display_path=resolved.relative,
676
+ )
677
+ if expected_hash is not None:
678
+ wanted = normalize_hash(expected_hash)
679
+ if not hashes_match(wanted, before_hash or ""):
680
+ raise ContentConflictError(
681
+ "file content changed since it was read; refusing to delete",
682
+ details={
683
+ "path": resolved.relative,
684
+ "expected_hash": wanted,
685
+ "actual_hash": before_hash,
686
+ },
687
+ )
688
+
689
+ operation_id: str | None = None
690
+ skipped: tuple[str, ...] = ()
691
+ if not dry_run:
692
+ if journal is not None:
693
+ begun = journal.begin(
694
+ operation_type="delete_file",
695
+ target_path=resolved.relative,
696
+ summary=f"Deleted {resolved.relative}.",
697
+ items=[
698
+ PendingItem(
699
+ relative_path=resolved.relative,
700
+ item_type="file",
701
+ before_exists=True,
702
+ after_exists=False,
703
+ before_bytes=before_bytes,
704
+ before_hash=before_hash,
705
+ before_mode=_target_mode(resolved),
706
+ )
707
+ ],
708
+ actor=actor,
709
+ task_id=task_id,
710
+ before_hash=before_hash,
711
+ )
712
+ operation_id = begun.operation_id
713
+ skipped = begun.skipped
714
+ try:
715
+ resolved.absolute.unlink()
716
+ except OSError as exc:
717
+ if journal is not None and operation_id is not None:
718
+ journal.abort(operation_id)
719
+ raise StorageError(
720
+ f"cannot delete file {resolved.relative}: "
721
+ f"{exc.strerror or type(exc).__name__}",
722
+ details={"path": resolved.relative},
723
+ ) from exc
724
+ fsync_directory(resolved.absolute.parent)
725
+ if journal is not None and operation_id is not None:
726
+ journal.commit(operation_id)
727
+
728
+ return MutationResult(
729
+ workspace=handle.name,
730
+ path=resolved.relative,
731
+ changed=True,
732
+ before_hash=before_hash,
733
+ after_hash=None,
734
+ summary=f"{'Would delete' if dry_run else 'Deleted'} {resolved.relative}.",
735
+ operation_id=operation_id,
736
+ undo_available=not dry_run and operation_id is not None and not skipped,
737
+ dry_run=dry_run,
738
+ items=(
739
+ OperationItemResult(
740
+ path=resolved.relative,
741
+ change="deleted",
742
+ before_hash=before_hash,
743
+ after_hash=None,
744
+ ),
745
+ ),
746
+ affected_count=1,
747
+ history_skipped=skipped,
748
+ )
749
+
750
+
751
+ # Re-exported for adapters that need to format a hash without importing safety.
752
+ __all__ = [
753
+ "SECRET_FLAG_HINT",
754
+ "copy_file",
755
+ "create_file",
756
+ "delete_file",
757
+ "format_digest",
758
+ "move_file",
759
+ "write_file",
760
+ ]
761
+
762
+ # Keep static analyzers honest about intentionally public helpers.
763
+ _ = (looks_binary, SymlinkWriteRefusedError, stat.S_IMODE)