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,1239 @@
1
+ """Text editing primitives.
2
+
3
+ Three surfaces are implemented here, in the order the design review gives them:
4
+
5
+ * :func:`replace_text` — exact-text replacement, the primary edit;
6
+ * :func:`replace_lines`, :func:`insert_lines`, :func:`delete_lines` — positional
7
+ editing for genuinely line-shaped work;
8
+ * :func:`apply_patch` — unified-diff application across one or more files.
9
+
10
+ Every application goes through the same journal sequence as a whole-file write:
11
+ blobs, pending record, atomic replacement, commit. Undo therefore needs no
12
+ separate code path.
13
+
14
+ Matching in :func:`replace_text` is staged, and the result names the stage that
15
+ matched:
16
+
17
+ 1. exact literal match — reported as ``"exact"``;
18
+ 2. whitespace-normalised match — reported as ``"normalised"`` and never applied
19
+ silently;
20
+ 3. no match — fails with the nearest candidate line numbers and their text.
21
+
22
+ Line numbers are 1-based and inclusive. A file's newline convention and
23
+ byte-order mark are preserved.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import difflib
30
+ import re
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Literal
34
+
35
+ from ..diffing import DiffLine, join_lines, split_lines, unified_diff_text
36
+ from ..errors import (
37
+ ContentConflictError,
38
+ FilekeeperError,
39
+ MatchCountMismatchError,
40
+ NoMatchError,
41
+ PartialRecoveryError,
42
+ PatchFailedError,
43
+ ValidationError,
44
+ storage_failure,
45
+ )
46
+ from ..history.journal import OperationJournal, PendingItem
47
+ from ..models import (
48
+ MutationResult,
49
+ OperationItemResult,
50
+ PatchFileResult,
51
+ PatchHunkResult,
52
+ )
53
+ from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
54
+ from ..safety.atomic import atomic_write_bytes, fsync_directory
55
+ from ..safety.hashing import hash_bytes, hashes_match, normalize_hash
56
+ from ..safety.textdata import TextProfile, decode_bytes, encode_text
57
+ from ..workspace import WorkspaceHandle
58
+ from .write import (
59
+ _after_write,
60
+ _check_size,
61
+ _default_file_mode,
62
+ _guard_sensitive,
63
+ _target_mode,
64
+ )
65
+
66
+ MATCH_STAGE_EXACT = "exact"
67
+ MATCH_STAGE_NORMALISED = "normalised"
68
+ MATCH_STAGE_NONE = "none"
69
+ _MATCH_POLICIES = {"first", "all"}
70
+ _INSERT_POSITIONS = {"before", "after", "end"}
71
+ _HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
72
+ _METADATA_PREFIXES = (
73
+ "diff ",
74
+ "index ",
75
+ "new file mode ",
76
+ "deleted file mode ",
77
+ "similarity index ",
78
+ "rename from ",
79
+ "rename to ",
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class _Loaded:
85
+ resolved: ResolvedPath
86
+ raw: bytes
87
+ text: str
88
+ profile: TextProfile
89
+ before_hash: str
90
+ before_mode: int
91
+ warnings: tuple[str, ...]
92
+
93
+
94
+ def _load(
95
+ handle: WorkspaceHandle,
96
+ path: str,
97
+ *,
98
+ allow_sensitive_write: bool,
99
+ ) -> _Loaded:
100
+ resolved = resolve_path(
101
+ handle.root,
102
+ path,
103
+ for_write=True,
104
+ must_exist=True,
105
+ expect="file",
106
+ state_dir=handle.state_dir,
107
+ )
108
+ warnings = _guard_sensitive(
109
+ handle, resolved, allow_sensitive_write=allow_sensitive_write
110
+ )
111
+ try:
112
+ raw = resolved.absolute.read_bytes()
113
+ except OSError as exc:
114
+ raise storage_failure("read", resolved.relative, exc) from exc
115
+ text, profile = decode_bytes(raw, path=resolved.relative)
116
+ mode = _target_mode(resolved)
117
+ return _Loaded(
118
+ resolved=resolved,
119
+ raw=raw,
120
+ text=text,
121
+ profile=profile,
122
+ before_hash=hash_bytes(raw),
123
+ before_mode=mode if mode is not None else _default_file_mode(),
124
+ warnings=warnings,
125
+ )
126
+
127
+
128
+ def _guard_hash(loaded: _Loaded, expected_hash: str | None) -> None:
129
+ if expected_hash is None:
130
+ return
131
+ wanted = normalize_hash(expected_hash)
132
+ if not hashes_match(wanted, loaded.before_hash):
133
+ raise ContentConflictError(
134
+ "file content changed since it was read; refusing to edit",
135
+ details={
136
+ "path": loaded.resolved.relative,
137
+ "expected_hash": wanted,
138
+ "actual_hash": loaded.before_hash,
139
+ },
140
+ )
141
+
142
+
143
+ def _guard_region(path: str, matched: str, expected_content: str | None) -> None:
144
+ if expected_content is None:
145
+ return
146
+ if matched != expected_content:
147
+ raise ContentConflictError(
148
+ "the target region does not match expected_content; refusing to edit",
149
+ details={
150
+ "path": path,
151
+ "expected_content": expected_content,
152
+ "actual_content": matched,
153
+ },
154
+ )
155
+
156
+
157
+ def _newline_for(profile: TextProfile) -> str:
158
+ return profile.newline or "\n"
159
+
160
+
161
+ def _leading_whitespace(text: str) -> str:
162
+ return text[: len(text) - len(text.lstrip(" \t"))]
163
+
164
+
165
+ def _replacement_lines(replacement: str, newline: str) -> list[DiffLine]:
166
+ """Turn caller content into lines that use the file's newline convention."""
167
+
168
+ normalised = replacement.replace("\r\n", "\n").replace("\r", "\n")
169
+ terminated = normalised.endswith("\n")
170
+ parts = normalised.split("\n")
171
+ if parts and parts[-1] == "":
172
+ parts = parts[:-1]
173
+ ending = newline or "\n"
174
+ lines = [DiffLine(part, ending) for part in parts]
175
+ if lines and not terminated:
176
+ lines[-1] = DiffLine(lines[-1].text, "")
177
+ return lines
178
+
179
+
180
+ def _replacement_block(replacement: str, indent: str, newline: str) -> list[DiffLine]:
181
+ """Replacement content for a whitespace-normalised match.
182
+
183
+ Relative indentation inside the caller's content is kept; the block is
184
+ re-based onto the indentation of the line it replaces.
185
+ """
186
+
187
+ normalised = replacement.replace("\r\n", "\n").replace("\r", "\n")
188
+ terminated = normalised.endswith("\n")
189
+ parts = normalised.split("\n")
190
+ if parts and parts[-1] == "":
191
+ parts = parts[:-1]
192
+ if not parts:
193
+ return []
194
+
195
+ base = _leading_whitespace(parts[0])
196
+ reindented: list[str] = []
197
+ for part in parts:
198
+ if part.strip() == "":
199
+ reindented.append("")
200
+ elif base and part.startswith(base):
201
+ reindented.append(indent + part[len(base) :])
202
+ else:
203
+ reindented.append(indent + part.lstrip(" \t"))
204
+
205
+ ending = newline or "\n"
206
+ lines = [DiffLine(text, ending) for text in reindented]
207
+ if not terminated:
208
+ lines[-1] = DiffLine(lines[-1].text, "")
209
+ return lines
210
+
211
+
212
+ def _candidates(text: str, old: str) -> list[dict[str, Any]]:
213
+ """Nearest lines to the requested text, for a no-match refusal."""
214
+
215
+ probe = old.strip()
216
+ lines = split_lines(text)
217
+ scored: list[tuple[float, int, str]] = []
218
+ for index, line in enumerate(lines, start=1):
219
+ ratio = difflib.SequenceMatcher(None, probe, line.text.strip()).ratio()
220
+ scored.append((ratio, index, line.text))
221
+ scored.sort(key=lambda item: item[0], reverse=True)
222
+ return [
223
+ {"line": number, "text": content}
224
+ for _, number, content in scored[:3]
225
+ ]
226
+
227
+
228
+ def _finish(
229
+ handle: WorkspaceHandle,
230
+ loaded: _Loaded,
231
+ new_text: str,
232
+ *,
233
+ operation_type: str,
234
+ past_verb: str,
235
+ dry_verb: str,
236
+ match_stage: str | None,
237
+ match_count: int | None,
238
+ dry_run: bool,
239
+ journal: OperationJournal | None,
240
+ actor: str | None,
241
+ task_id: str | None,
242
+ ) -> MutationResult:
243
+ data = encode_text(
244
+ new_text, encoding=loaded.profile.encoding, has_bom=loaded.profile.has_bom
245
+ )
246
+ _check_size(handle, data, loaded.resolved.relative)
247
+
248
+ preview: str | None = None
249
+ if dry_run:
250
+ preview = unified_diff_text(
251
+ loaded.text,
252
+ new_text,
253
+ fromfile=f"a/{loaded.resolved.relative}",
254
+ tofile=f"b/{loaded.resolved.relative}",
255
+ )
256
+
257
+ result = _after_write(
258
+ handle,
259
+ loaded.resolved,
260
+ data,
261
+ operation_type=operation_type,
262
+ change="modified",
263
+ before_hash=loaded.before_hash,
264
+ before_bytes=loaded.raw,
265
+ before_exists=True,
266
+ before_mode=loaded.before_mode,
267
+ dry_run=dry_run,
268
+ warnings=loaded.warnings,
269
+ past_verb=past_verb,
270
+ dry_verb=dry_verb,
271
+ journal=journal,
272
+ actor=actor,
273
+ task_id=task_id,
274
+ )
275
+ return dataclasses.replace(
276
+ result,
277
+ match_stage=match_stage,
278
+ match_count=match_count,
279
+ preview_diff=preview,
280
+ )
281
+
282
+
283
+ def _exact_regions(text: str, old: str) -> list[tuple[int, int]]:
284
+ regions: list[tuple[int, int]] = []
285
+ start = 0
286
+ while True:
287
+ found = text.find(old, start)
288
+ if found < 0:
289
+ break
290
+ regions.append((found, found + len(old)))
291
+ start = found + len(old)
292
+ return regions
293
+
294
+
295
+ def _normalised_regions(text: str, old: str) -> list[tuple[int, int]]:
296
+ old_parts = old.splitlines()
297
+ if not old_parts:
298
+ return []
299
+ keys = [part.strip() for part in old_parts]
300
+ lines = split_lines(text)
301
+ length = len(keys)
302
+ regions: list[tuple[int, int]] = []
303
+ index = 0
304
+ while index + length <= len(lines):
305
+ if all(lines[index + offset].text.strip() == keys[offset] for offset in range(length)):
306
+ regions.append((index, index + length))
307
+ index += length
308
+ else:
309
+ index += 1
310
+ return regions
311
+
312
+
313
+ def replace_text(
314
+ handle: WorkspaceHandle,
315
+ path: str,
316
+ old: str,
317
+ new: str,
318
+ *,
319
+ match: str,
320
+ expected_hash: str | None = None,
321
+ expected_content: str | None = None,
322
+ expected_match_count: int | None = None,
323
+ allow_sensitive_write: bool = False,
324
+ dry_run: bool = False,
325
+ journal: OperationJournal | None = None,
326
+ actor: str | None = None,
327
+ task_id: str | None = None,
328
+ ) -> MutationResult:
329
+ """Replace literal text, reporting which matching stage matched.
330
+
331
+ ``match`` is required and is either ``"first"`` or ``"all"``. A file that
332
+ does not contain the text fails with ``NO_MATCH`` and the nearest lines;
333
+ ``expected_match_count`` turns a different number of found matches into a
334
+ refusal. ``expected_content`` guards a single matched region and must equal
335
+ its exact text, including indentation.
336
+ """
337
+
338
+ if match not in _MATCH_POLICIES:
339
+ raise ValidationError(
340
+ "match policy must be 'first' or 'all'", details={"match": match}
341
+ )
342
+ if old == "":
343
+ raise ValidationError("replacement target must not be empty")
344
+ if expected_match_count is not None and expected_match_count < 0:
345
+ raise ValidationError("expected_match_count must not be negative")
346
+
347
+ loaded = _load(handle, path, allow_sensitive_write=allow_sensitive_write)
348
+ _guard_hash(loaded, expected_hash)
349
+
350
+ text = loaded.text
351
+ stage = MATCH_STAGE_EXACT
352
+ regions = _exact_regions(text, old)
353
+ if not regions:
354
+ stage = MATCH_STAGE_NORMALISED
355
+ normalised = _normalised_regions(text, old)
356
+ if normalised:
357
+ regions = normalised
358
+ else:
359
+ stage = MATCH_STAGE_NONE
360
+
361
+ if not regions:
362
+ raise NoMatchError(
363
+ f"no literal or whitespace-normalised match for the requested text in "
364
+ f"{loaded.resolved.relative}",
365
+ details={
366
+ "path": loaded.resolved.relative,
367
+ "match_stage": stage,
368
+ "candidates": _candidates(text, old),
369
+ },
370
+ )
371
+
372
+ found = len(regions)
373
+ if expected_match_count is not None and found != expected_match_count:
374
+ raise MatchCountMismatchError(
375
+ "the requested text matched a different number of times than expected",
376
+ details={
377
+ "path": loaded.resolved.relative,
378
+ "match_stage": stage,
379
+ "expected_match_count": expected_match_count,
380
+ "actual_match_count": found,
381
+ },
382
+ )
383
+
384
+ selected = regions[:1] if match == "first" else regions
385
+ lines = split_lines(text)
386
+ blocks: list[str] = []
387
+ for start, end in selected:
388
+ if stage == MATCH_STAGE_EXACT:
389
+ blocks.append(text[start:end])
390
+ else:
391
+ blocks.append(join_lines(lines[start:end]))
392
+
393
+ if expected_content is not None:
394
+ if len(selected) != 1:
395
+ raise ContentConflictError(
396
+ "expected_content guards one target region; narrow the match policy",
397
+ details={
398
+ "path": loaded.resolved.relative,
399
+ "matched_regions": len(selected),
400
+ },
401
+ )
402
+ _guard_region(loaded.resolved.relative, blocks[0], expected_content)
403
+
404
+ if stage == MATCH_STAGE_EXACT:
405
+ new_text = text
406
+ for start, end in reversed(selected):
407
+ new_text = new_text[:start] + new + new_text[end:]
408
+ else:
409
+ newline = _newline_for(loaded.profile)
410
+ replacements: list[tuple[int, int, list[DiffLine]]] = []
411
+ for start, end in selected:
412
+ indent = _leading_whitespace(lines[start].text) if start < len(lines) else ""
413
+ replacements.append((start, end, _replacement_block(new, indent, newline)))
414
+ for start, end, block in reversed(replacements):
415
+ if block and not new.endswith(("\n", "\r")):
416
+ block[-1] = DiffLine(block[-1].text, lines[end - 1].ending)
417
+ lines[start:end] = block
418
+ new_text = join_lines(lines)
419
+
420
+ applied = len(selected)
421
+ stage_word = "exact" if stage == MATCH_STAGE_EXACT else "whitespace-normalised"
422
+ plural = "" if applied == 1 else "es"
423
+ verb = f"Replaced {applied} {stage_word} match{plural} in"
424
+ dry_verb = f"Would replace {applied} {stage_word} match{plural} in"
425
+
426
+ return _finish(
427
+ handle,
428
+ loaded,
429
+ new_text,
430
+ operation_type="replace_text",
431
+ past_verb=verb,
432
+ dry_verb=dry_verb,
433
+ match_stage=stage,
434
+ match_count=found,
435
+ dry_run=dry_run,
436
+ journal=journal,
437
+ actor=actor,
438
+ task_id=task_id,
439
+ )
440
+
441
+
442
+ def _validate_range(start_line: int, end_line: int, line_count: int, path: str) -> None:
443
+ if start_line < 1 or end_line < 1:
444
+ raise ValidationError(
445
+ "line numbers are 1-based and must be positive",
446
+ details={"path": path, "start_line": start_line, "end_line": end_line},
447
+ )
448
+ if start_line > end_line:
449
+ raise ValidationError(
450
+ "start_line must not be greater than end_line",
451
+ details={"path": path, "start_line": start_line, "end_line": end_line},
452
+ )
453
+ if end_line > line_count:
454
+ raise ValidationError(
455
+ "line range is beyond the end of the file; ranges are never clamped",
456
+ details={
457
+ "path": path,
458
+ "start_line": start_line,
459
+ "end_line": end_line,
460
+ "line_count": line_count,
461
+ },
462
+ )
463
+
464
+
465
+ def replace_lines(
466
+ handle: WorkspaceHandle,
467
+ path: str,
468
+ start_line: int,
469
+ end_line: int,
470
+ replacement: str,
471
+ *,
472
+ expected_content: str | None = None,
473
+ expected_hash: str | None = None,
474
+ dry_run: bool = False,
475
+ journal: OperationJournal | None = None,
476
+ actor: str | None = None,
477
+ task_id: str | None = None,
478
+ ) -> MutationResult:
479
+ """Replace an inclusive 1-based line range with ``replacement``."""
480
+
481
+ loaded = _load(handle, path, allow_sensitive_write=False)
482
+ _guard_hash(loaded, expected_hash)
483
+ lines = split_lines(loaded.text)
484
+ _validate_range(start_line, end_line, len(lines), loaded.resolved.relative)
485
+ region = join_lines(lines[start_line - 1 : end_line])
486
+ _guard_region(loaded.resolved.relative, region, expected_content)
487
+
488
+ newline = _newline_for(loaded.profile)
489
+ replacement_lines = _replacement_lines(replacement, newline)
490
+ if replacement_lines and not replacement.endswith(("\n", "\r")):
491
+ replacement_lines[-1] = DiffLine(
492
+ replacement_lines[-1].text, lines[end_line - 1].ending
493
+ )
494
+ new_lines = lines[: start_line - 1] + replacement_lines + lines[end_line:]
495
+ new_text = join_lines(new_lines)
496
+
497
+ span = (
498
+ f"line {start_line}"
499
+ if start_line == end_line
500
+ else f"lines {start_line}-{end_line}"
501
+ )
502
+ return _finish(
503
+ handle,
504
+ loaded,
505
+ new_text,
506
+ operation_type="replace_lines",
507
+ past_verb=f"Replaced {span} of",
508
+ dry_verb=f"Would replace {span} of",
509
+ match_stage=None,
510
+ match_count=None,
511
+ dry_run=dry_run,
512
+ journal=journal,
513
+ actor=actor,
514
+ task_id=task_id,
515
+ )
516
+
517
+
518
+ def insert_lines(
519
+ handle: WorkspaceHandle,
520
+ path: str,
521
+ line: int | None,
522
+ content: str,
523
+ *,
524
+ position: str = "after",
525
+ expected_content: str | None = None,
526
+ expected_hash: str | None = None,
527
+ dry_run: bool = False,
528
+ journal: OperationJournal | None = None,
529
+ actor: str | None = None,
530
+ task_id: str | None = None,
531
+ ) -> MutationResult:
532
+ """Insert content before or after a line, or at the end of the file.
533
+
534
+ End-of-file insertion is requested with ``position="end"``; ``line`` must be
535
+ omitted there. It is never expressed by overloading a line number.
536
+ """
537
+
538
+ if position not in _INSERT_POSITIONS:
539
+ raise ValidationError(
540
+ "position must be 'before', 'after' or 'end'", details={"position": position}
541
+ )
542
+ loaded = _load(handle, path, allow_sensitive_write=False)
543
+ _guard_hash(loaded, expected_hash)
544
+ lines = split_lines(loaded.text)
545
+ count = len(lines)
546
+
547
+ if position == "end":
548
+ if line is not None:
549
+ raise ValidationError(
550
+ "position 'end' does not take a line number", details={"line": line}
551
+ )
552
+ index = count
553
+ anchor = lines[-1].raw if lines else ""
554
+ where = "the end of"
555
+ else:
556
+ if line is None:
557
+ raise ValidationError(
558
+ f"position '{position}' requires a line number", details={"position": position}
559
+ )
560
+ if line < 1 or line > count:
561
+ raise ValidationError(
562
+ "line number is beyond the end of the file; numbers are never clamped",
563
+ details={"path": loaded.resolved.relative, "line": line, "line_count": count},
564
+ )
565
+ index = line - 1 if position == "before" else line
566
+ anchor = lines[line - 1].raw
567
+ where = f"{position} line {line} of"
568
+
569
+ _guard_region(loaded.resolved.relative, anchor, expected_content)
570
+
571
+ newline = _newline_for(loaded.profile)
572
+ if index > 0 and lines[index - 1].ending == "":
573
+ lines[index - 1] = DiffLine(lines[index - 1].text, newline)
574
+
575
+ inserted = _replacement_lines(content, newline)
576
+ if index < count and inserted and inserted[-1].ending == "":
577
+ inserted[-1] = DiffLine(inserted[-1].text, newline)
578
+
579
+ lines[index:index] = inserted
580
+ new_text = join_lines(lines)
581
+
582
+ return _finish(
583
+ handle,
584
+ loaded,
585
+ new_text,
586
+ operation_type="insert_lines",
587
+ past_verb=f"Inserted {len(inserted)} line(s) at {where}",
588
+ dry_verb=f"Would insert {len(inserted)} line(s) at {where}",
589
+ match_stage=None,
590
+ match_count=None,
591
+ dry_run=dry_run,
592
+ journal=journal,
593
+ actor=actor,
594
+ task_id=task_id,
595
+ )
596
+
597
+
598
+ def delete_lines(
599
+ handle: WorkspaceHandle,
600
+ path: str,
601
+ start_line: int,
602
+ end_line: int,
603
+ *,
604
+ expected_content: str | None = None,
605
+ expected_hash: str | None = None,
606
+ dry_run: bool = False,
607
+ journal: OperationJournal | None = None,
608
+ actor: str | None = None,
609
+ task_id: str | None = None,
610
+ ) -> MutationResult:
611
+ """Delete an inclusive 1-based line range."""
612
+
613
+ loaded = _load(handle, path, allow_sensitive_write=False)
614
+ _guard_hash(loaded, expected_hash)
615
+ lines = split_lines(loaded.text)
616
+ _validate_range(start_line, end_line, len(lines), loaded.resolved.relative)
617
+ region = join_lines(lines[start_line - 1 : end_line])
618
+ _guard_region(loaded.resolved.relative, region, expected_content)
619
+
620
+ new_lines = lines[: start_line - 1] + lines[end_line:]
621
+ new_text = join_lines(new_lines)
622
+ span = (
623
+ f"line {start_line}"
624
+ if start_line == end_line
625
+ else f"lines {start_line}-{end_line}"
626
+ )
627
+ return _finish(
628
+ handle,
629
+ loaded,
630
+ new_text,
631
+ operation_type="delete_lines",
632
+ past_verb=f"Deleted {span} of",
633
+ dry_verb=f"Would delete {span} of",
634
+ match_stage=None,
635
+ match_count=None,
636
+ dry_run=dry_run,
637
+ journal=journal,
638
+ actor=actor,
639
+ task_id=task_id,
640
+ )
641
+
642
+
643
+ # -- unified diff application ------------------------------------------------
644
+
645
+
646
+ @dataclass(frozen=True, slots=True)
647
+ class _BodyLine:
648
+ prefix: str
649
+ text: str
650
+ terminated: bool = True
651
+
652
+
653
+ @dataclass(frozen=True, slots=True)
654
+ class _Hunk:
655
+ old_start: int
656
+ old_count: int
657
+ new_start: int
658
+ new_count: int
659
+ body: tuple[_BodyLine, ...]
660
+
661
+
662
+ @dataclass(frozen=True, slots=True)
663
+ class _FilePatch:
664
+ old_path: str
665
+ new_path: str
666
+ hunks: tuple[_Hunk, ...]
667
+
668
+
669
+ @dataclass(frozen=True, slots=True)
670
+ class _Plan:
671
+ relative: str
672
+ absolute: Path
673
+ before_exists: bool
674
+ after_exists: bool
675
+ before_bytes: bytes | None
676
+ after_bytes: bytes | None
677
+ before_hash: str | None
678
+ after_hash: str | None
679
+ before_mode: int | None
680
+ after_mode: int | None
681
+ change: Literal["created", "modified", "deleted", "unchanged"]
682
+ hunks: tuple[PatchHunkResult, ...]
683
+
684
+
685
+ def _syntax(message: str, line: int) -> PatchFailedError:
686
+ return PatchFailedError(
687
+ f"invalid patch: {message}", details={"line": line, "reason": "syntax"}
688
+ )
689
+
690
+
691
+ def _parse_patch(patch_text: str) -> tuple[_FilePatch, ...]:
692
+ lines = patch_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
693
+ files: list[_FilePatch] = []
694
+ index = 0
695
+ while index < len(lines):
696
+ line = lines[index]
697
+ if line.startswith("--- "):
698
+ old_path = line[4:].strip().split("\t")[0]
699
+ if index + 1 >= len(lines) or not lines[index + 1].startswith("+++ "):
700
+ raise _syntax("a --- header must be followed by a +++ header", index + 1)
701
+ new_path = lines[index + 1][4:].strip().split("\t")[0]
702
+ index += 2
703
+ hunks: list[_Hunk] = []
704
+ while index < len(lines):
705
+ current = lines[index]
706
+ if current.startswith("diff "):
707
+ break
708
+ if (
709
+ current.startswith("--- ")
710
+ and index + 1 < len(lines)
711
+ and lines[index + 1].startswith("+++ ")
712
+ ):
713
+ break
714
+ if current.startswith("@@"):
715
+ match = _HUNK_HEADER.match(current)
716
+ if match is None:
717
+ raise _syntax("malformed hunk header", index + 1)
718
+ old_start = int(match.group(1))
719
+ old_count = int(match.group(2) or "1")
720
+ new_start = int(match.group(3))
721
+ new_count = int(match.group(4) or "1")
722
+ index += 1
723
+ body: list[_BodyLine] = []
724
+ remaining_old = old_count
725
+ remaining_new = new_count
726
+ while index < len(lines) and (remaining_old > 0 or remaining_new > 0):
727
+ body_line = lines[index]
728
+ prefix = body_line[:1]
729
+ if prefix == "\\":
730
+ if not body:
731
+ raise _syntax("newline marker has no preceding line", index + 1)
732
+ body[-1] = dataclasses.replace(body[-1], terminated=False)
733
+ index += 1
734
+ continue
735
+ if prefix == " ":
736
+ body.append(_BodyLine(" ", body_line[1:]))
737
+ remaining_old -= 1
738
+ remaining_new -= 1
739
+ elif prefix == "-":
740
+ body.append(_BodyLine("-", body_line[1:]))
741
+ remaining_old -= 1
742
+ elif prefix == "+":
743
+ body.append(_BodyLine("+", body_line[1:]))
744
+ remaining_new -= 1
745
+ else:
746
+ raise _syntax(
747
+ "hunk is truncated or has an unknown line prefix",
748
+ index + 1,
749
+ )
750
+ index += 1
751
+ if remaining_old != 0 or remaining_new != 0:
752
+ raise _syntax("hunk line counts do not match its header", index + 1)
753
+ if index < len(lines) and lines[index].startswith("\\"):
754
+ if not body:
755
+ raise _syntax("newline marker has no preceding line", index + 1)
756
+ body[-1] = dataclasses.replace(body[-1], terminated=False)
757
+ index += 1
758
+ hunks.append(
759
+ _Hunk(old_start, old_count, new_start, new_count, tuple(body))
760
+ )
761
+ continue
762
+ if current.strip() == "" or current.startswith(_METADATA_PREFIXES):
763
+ index += 1
764
+ continue
765
+ raise _syntax("unexpected line between hunks", index + 1)
766
+ if not hunks:
767
+ raise _syntax("a file section has no hunks", index + 1)
768
+ files.append(_FilePatch(old_path, new_path, tuple(hunks)))
769
+ continue
770
+ if line.strip() == "" or line.startswith(_METADATA_PREFIXES):
771
+ index += 1
772
+ continue
773
+ raise _syntax("patch must begin with a ---/+++ file header", index + 1)
774
+ if not files:
775
+ raise PatchFailedError(
776
+ "invalid patch: no file sections found", details={"reason": "syntax"}
777
+ )
778
+ return tuple(files)
779
+
780
+
781
+ def _strip_prefix(path: str, side: Literal["a", "b"]) -> str:
782
+ prefix = f"{side}/"
783
+ return path[len(prefix) :] if path.startswith(prefix) else path
784
+
785
+
786
+ def _hunk_line(line: _BodyLine, newline: str) -> DiffLine:
787
+ ending = (newline or "\n") if line.terminated else ""
788
+ return DiffLine(line.text, ending)
789
+
790
+
791
+ def _block_matches(lines: list[DiffLine], start: int, block: list[_BodyLine]) -> bool:
792
+ if start < 0 or start + len(block) > len(lines):
793
+ return False
794
+ for offset, body_line in enumerate(block):
795
+ if lines[start + offset].text != body_line.text:
796
+ return False
797
+ last = block[-1]
798
+ if not last.terminated and lines[start + len(block) - 1].ending != "":
799
+ return False
800
+ return True
801
+
802
+
803
+ def _apply_hunks(
804
+ lines: list[DiffLine],
805
+ hunks: tuple[_Hunk, ...],
806
+ *,
807
+ newline: str,
808
+ relative: str,
809
+ ) -> tuple[list[DiffLine], tuple[PatchHunkResult, ...]]:
810
+ working = list(lines)
811
+ results: list[PatchHunkResult] = []
812
+ offset = 0
813
+
814
+ for number, hunk in enumerate(hunks, start=1):
815
+ old_block = [line for line in hunk.body if line.prefix in {" ", "-"}]
816
+ new_block = [line for line in hunk.body if line.prefix in {" ", "+"}]
817
+
818
+ if hunk.old_count == 0:
819
+ position = hunk.old_start + offset
820
+ if position < 0 or position > len(working):
821
+ raise PatchFailedError(
822
+ f"hunk {number} inserts beyond the end of {relative}",
823
+ details={
824
+ "path": relative,
825
+ "hunk": number,
826
+ "reason": "out_of_range",
827
+ },
828
+ )
829
+ else:
830
+ expected = hunk.old_start - 1 + offset
831
+ matches = [
832
+ start
833
+ for start in range(0, len(working) - len(old_block) + 1)
834
+ if _block_matches(working, start, old_block)
835
+ ]
836
+ if not matches:
837
+ raise PatchFailedError(
838
+ f"hunk {number} does not match {relative}; the preimage is absent",
839
+ details={"path": relative, "hunk": number, "reason": "preimage_missing"},
840
+ )
841
+ if expected in matches:
842
+ position = expected
843
+ elif len(matches) == 1:
844
+ position = matches[0]
845
+ else:
846
+ raise PatchFailedError(
847
+ f"hunk {number} is ambiguous in {relative}: its preimage appears "
848
+ f"{len(matches)} times and the stated position does not match",
849
+ details={
850
+ "path": relative,
851
+ "hunk": number,
852
+ "reason": "ambiguous",
853
+ "positions": [item + 1 for item in matches],
854
+ },
855
+ )
856
+
857
+ working[position : position + len(old_block)] = [
858
+ _hunk_line(line, newline) for line in new_block
859
+ ]
860
+ offset += len(new_block) - len(old_block)
861
+ results.append(
862
+ PatchHunkResult(
863
+ old_start=hunk.old_start,
864
+ old_count=hunk.old_count,
865
+ new_start=hunk.new_start,
866
+ new_count=hunk.new_count,
867
+ status="applied",
868
+ )
869
+ )
870
+ return working, tuple(results)
871
+
872
+
873
+ def _plan_file(
874
+ handle: WorkspaceHandle,
875
+ patch: _FilePatch,
876
+ *,
877
+ allow_sensitive_write: bool,
878
+ ) -> tuple[_Plan, tuple[str, ...]]:
879
+ create = patch.old_path == "/dev/null"
880
+ delete = patch.new_path == "/dev/null"
881
+ if create:
882
+ relative = _strip_prefix(patch.new_path, "b")
883
+ elif delete:
884
+ relative = _strip_prefix(patch.old_path, "a")
885
+ else:
886
+ old_relative = _strip_prefix(patch.old_path, "a")
887
+ new_relative = _strip_prefix(patch.new_path, "b")
888
+ if old_relative != new_relative:
889
+ raise PatchFailedError(
890
+ "renaming patches are not supported; the old and new paths must match",
891
+ details={"path": old_relative, "reason": "rename"},
892
+ )
893
+ relative = old_relative
894
+
895
+ resolved = resolve_path(
896
+ handle.root,
897
+ relative,
898
+ for_write=True,
899
+ state_dir=handle.state_dir,
900
+ )
901
+ warnings = _guard_sensitive(
902
+ handle, resolved, allow_sensitive_write=allow_sensitive_write
903
+ )
904
+
905
+ newline = ""
906
+ if create:
907
+ if resolved.exists:
908
+ raise PatchFailedError(
909
+ f"cannot create {resolved.relative}: it already exists",
910
+ details={"path": resolved.relative, "reason": "already_exists"},
911
+ )
912
+ ensure_parent_directory(resolved)
913
+ before_bytes: bytes | None = None
914
+ before_hash: str | None = None
915
+ before_mode: int | None = None
916
+ before_text = ""
917
+ profile_encoding = "utf-8"
918
+ has_bom = False
919
+ else:
920
+ if not resolved.exists:
921
+ raise PatchFailedError(
922
+ f"cannot patch {resolved.relative}: the path does not exist",
923
+ details={"path": resolved.relative, "reason": "missing"},
924
+ )
925
+ try:
926
+ before_bytes = resolved.absolute.read_bytes()
927
+ except OSError as exc:
928
+ raise storage_failure("read", resolved.relative, exc) from exc
929
+ before_text, profile = decode_bytes(before_bytes, path=resolved.relative)
930
+ before_hash = hash_bytes(before_bytes)
931
+ before_mode = _target_mode(resolved)
932
+ profile_encoding = profile.encoding
933
+ has_bom = profile.has_bom
934
+ newline = profile.newline
935
+
936
+ if create:
937
+ newline = ""
938
+
939
+ lines = split_lines(before_text)
940
+ newline_for_file = newline or "\n"
941
+ after_lines, hunk_results = _apply_hunks(
942
+ lines, patch.hunks, newline=newline_for_file, relative=relative
943
+ )
944
+ after_text = join_lines(after_lines)
945
+
946
+ if delete and after_text != "":
947
+ raise PatchFailedError(
948
+ f"delete patch does not remove all of {relative}",
949
+ details={"path": relative, "reason": "incomplete_delete"},
950
+ )
951
+
952
+ if delete:
953
+ after_bytes: bytes | None = None
954
+ after_hash: str | None = None
955
+ after_exists = False
956
+ change: Literal["created", "modified", "deleted", "unchanged"] = "deleted"
957
+ else:
958
+ after_bytes = encode_text(
959
+ after_text, encoding=profile_encoding, has_bom=has_bom
960
+ )
961
+ _check_size(handle, after_bytes, relative)
962
+ after_hash = hash_bytes(after_bytes)
963
+ after_exists = True
964
+ change = "created" if create else "modified"
965
+
966
+ plan = _Plan(
967
+ relative=relative,
968
+ absolute=resolved.absolute,
969
+ before_exists=not create,
970
+ after_exists=after_exists,
971
+ before_bytes=before_bytes,
972
+ after_bytes=after_bytes,
973
+ before_hash=before_hash,
974
+ after_hash=after_hash,
975
+ before_mode=before_mode,
976
+ after_mode=before_mode if not create else _default_file_mode(),
977
+ change=change,
978
+ hunks=hunk_results,
979
+ )
980
+ return plan, warnings
981
+
982
+
983
+ def _roll_back(plans: list[_Plan]) -> tuple[list[str], list[str]]:
984
+ restored: list[str] = []
985
+ failed: list[str] = []
986
+ for plan in reversed(plans):
987
+ try:
988
+ if plan.before_exists:
989
+ atomic_write_bytes(
990
+ plan.absolute,
991
+ plan.before_bytes or b"",
992
+ mode=plan.before_mode,
993
+ fsync=True,
994
+ display_path=plan.relative,
995
+ )
996
+ else:
997
+ plan.absolute.unlink(missing_ok=True)
998
+ fsync_directory(plan.absolute.parent)
999
+ restored.append(plan.relative)
1000
+ except Exception:
1001
+ failed.append(plan.relative)
1002
+ return restored, failed
1003
+
1004
+
1005
+ def _failure_cause(plan: _Plan | None, exc: BaseException) -> str:
1006
+ """Describe a patch failure without naming a resolved host path.
1007
+
1008
+ A raw ``OSError``'s text embeds the absolute path it was given, so only its
1009
+ standard reason phrase is used, together with the workspace-relative path of
1010
+ the file being changed. Errors the project already builds carry a
1011
+ caller-facing message and are passed through unchanged; when that message
1012
+ names no file of its own, the relative path is appended so the cause still
1013
+ says which file failed.
1014
+
1015
+ This helper must never raise: it runs while unwinding a failed patch, and a
1016
+ fault here would mask the failure it is describing.
1017
+ """
1018
+
1019
+ relative = plan.relative if plan is not None else None
1020
+ if isinstance(exc, FilekeeperError):
1021
+ try:
1022
+ message = str(exc)
1023
+ except Exception: # pragma: no cover - a faulty __str__ must not mask the failure
1024
+ message = type(exc).__name__
1025
+ if relative is None or relative in message:
1026
+ return message
1027
+ return f"{message} ({relative})"
1028
+
1029
+ try:
1030
+ reason = getattr(exc, "strerror", None)
1031
+ except Exception: # pragma: no cover - exotic exception objects
1032
+ reason = None
1033
+ if not isinstance(reason, str) or not reason:
1034
+ reason = type(exc).__name__
1035
+ if relative is None:
1036
+ return reason
1037
+ return f"cannot modify {relative}: {reason}"
1038
+
1039
+
1040
+ def apply_patch(
1041
+ handle: WorkspaceHandle,
1042
+ patch_text: str,
1043
+ *,
1044
+ dry_run: bool = False,
1045
+ allow_sensitive_write: bool = False,
1046
+ journal: OperationJournal | None = None,
1047
+ actor: str | None = None,
1048
+ task_id: str | None = None,
1049
+ ) -> MutationResult:
1050
+ """Apply a unified diff after validating every file and hunk.
1051
+
1052
+ No partial patch is applied silently. If a later file fails after earlier
1053
+ ones were written, the written files are rolled back to their pre-patch
1054
+ bytes and the failure says what was rolled back. A multi-file application
1055
+ is **recoverable through history, never atomic across files** — undo
1056
+ restores every file as one logical operation.
1057
+ """
1058
+
1059
+ if not patch_text.strip():
1060
+ raise PatchFailedError(
1061
+ "invalid patch: the patch is empty", details={"reason": "syntax"}
1062
+ )
1063
+
1064
+ patches = _parse_patch(patch_text)
1065
+ seen: set[str] = set()
1066
+ plans: list[_Plan] = []
1067
+ warnings: list[str] = []
1068
+ for patch in patches:
1069
+ plan, plan_warnings = _plan_file(
1070
+ handle, patch, allow_sensitive_write=allow_sensitive_write
1071
+ )
1072
+ if plan.relative in seen:
1073
+ raise PatchFailedError(
1074
+ f"the patch names {plan.relative} more than once",
1075
+ details={"path": plan.relative, "reason": "duplicate"},
1076
+ )
1077
+ seen.add(plan.relative)
1078
+ plans.append(plan)
1079
+ warnings.extend(plan_warnings)
1080
+
1081
+ if dry_run:
1082
+ plans = [
1083
+ dataclasses.replace(
1084
+ plan,
1085
+ hunks=tuple(
1086
+ dataclasses.replace(hunk, status="would_apply")
1087
+ for hunk in plan.hunks
1088
+ ),
1089
+ )
1090
+ for plan in plans
1091
+ ]
1092
+
1093
+ file_count = len(plans)
1094
+ primary = plans[0]
1095
+ if file_count > 1:
1096
+ warnings.append(
1097
+ "a multi-file patch is recoverable through history, not atomic across files"
1098
+ )
1099
+
1100
+ file_results = tuple(
1101
+ PatchFileResult(
1102
+ path=plan.relative,
1103
+ change=plan.change,
1104
+ applied=True,
1105
+ before_hash=plan.before_hash,
1106
+ after_hash=plan.after_hash,
1107
+ hunks=plan.hunks,
1108
+ )
1109
+ for plan in plans
1110
+ )
1111
+ item_results = tuple(
1112
+ OperationItemResult(
1113
+ path=plan.relative,
1114
+ change=plan.change,
1115
+ before_hash=plan.before_hash,
1116
+ after_hash=plan.after_hash,
1117
+ size_bytes=len(plan.after_bytes) if plan.after_bytes is not None else None,
1118
+ )
1119
+ for plan in plans
1120
+ )
1121
+
1122
+ if dry_run:
1123
+ return MutationResult(
1124
+ workspace=handle.name,
1125
+ path=primary.relative,
1126
+ changed=True,
1127
+ before_hash=primary.before_hash,
1128
+ after_hash=primary.after_hash,
1129
+ summary=f"Would apply a patch touching {file_count} file(s).",
1130
+ dry_run=True,
1131
+ warnings=tuple(warnings),
1132
+ items=item_results,
1133
+ affected_count=file_count,
1134
+ preview_diff=patch_text,
1135
+ patch_files=file_results,
1136
+ )
1137
+
1138
+ pending = [
1139
+ PendingItem(
1140
+ relative_path=plan.relative,
1141
+ item_type="file",
1142
+ before_exists=plan.before_exists,
1143
+ after_exists=plan.after_exists,
1144
+ before_bytes=plan.before_bytes,
1145
+ after_bytes=plan.after_bytes,
1146
+ before_hash=plan.before_hash,
1147
+ after_hash=plan.after_hash,
1148
+ before_mode=plan.before_mode,
1149
+ after_mode=plan.after_mode,
1150
+ )
1151
+ for plan in plans
1152
+ ]
1153
+
1154
+ operation_id: str | None = None
1155
+ skipped: tuple[str, ...] = ()
1156
+ active: list[_Plan] = []
1157
+ if journal is not None:
1158
+ begun = journal.begin(
1159
+ operation_type="apply_patch",
1160
+ target_path=primary.relative if file_count == 1 else None,
1161
+ summary=f"Applied a patch touching {file_count} file(s).",
1162
+ items=pending,
1163
+ actor=actor,
1164
+ task_id=task_id,
1165
+ before_hash=primary.before_hash,
1166
+ after_hash=primary.after_hash,
1167
+ metadata={"file_count": file_count},
1168
+ )
1169
+ operation_id = begun.operation_id
1170
+ skipped = begun.skipped
1171
+ warnings.extend(begun.warnings)
1172
+
1173
+ current: _Plan | None = None
1174
+ try:
1175
+ for plan in plans:
1176
+ active.append(plan)
1177
+ current = plan
1178
+ if plan.after_exists:
1179
+ atomic_write_bytes(
1180
+ plan.absolute,
1181
+ plan.after_bytes or b"",
1182
+ mode=plan.after_mode,
1183
+ fsync=handle.config.fsync,
1184
+ display_path=plan.relative,
1185
+ )
1186
+ else:
1187
+ plan.absolute.unlink()
1188
+ fsync_directory(plan.absolute.parent)
1189
+ except Exception as exc:
1190
+ restored, failed = _roll_back(active)
1191
+ if journal is not None and operation_id is not None:
1192
+ journal.abort(operation_id)
1193
+ if failed:
1194
+ raise PartialRecoveryError(
1195
+ "patch failed and could not be fully rolled back; these paths were "
1196
+ "restored and these were not: " + ", ".join(failed),
1197
+ details={
1198
+ "restored": restored,
1199
+ "not_restored": failed,
1200
+ "cause": _failure_cause(current, exc),
1201
+ },
1202
+ ) from exc
1203
+ raise PatchFailedError(
1204
+ "patch failed; every affected file was restored to its pre-patch state",
1205
+ details={"rolled_back": restored, "cause": _failure_cause(current, exc)},
1206
+ ) from exc
1207
+
1208
+ undo_available = False
1209
+ if journal is not None and operation_id is not None:
1210
+ journal.commit(operation_id)
1211
+ undo_available = not skipped
1212
+
1213
+ return MutationResult(
1214
+ workspace=handle.name,
1215
+ path=primary.relative,
1216
+ changed=True,
1217
+ before_hash=primary.before_hash,
1218
+ after_hash=primary.after_hash,
1219
+ summary=f"Applied a patch touching {file_count} file(s).",
1220
+ operation_id=operation_id,
1221
+ undo_available=undo_available,
1222
+ warnings=tuple(warnings),
1223
+ items=item_results,
1224
+ affected_count=file_count,
1225
+ history_skipped=skipped,
1226
+ patch_files=file_results,
1227
+ )
1228
+
1229
+
1230
+ __all__ = [
1231
+ "MATCH_STAGE_EXACT",
1232
+ "MATCH_STAGE_NONE",
1233
+ "MATCH_STAGE_NORMALISED",
1234
+ "apply_patch",
1235
+ "delete_lines",
1236
+ "insert_lines",
1237
+ "replace_lines",
1238
+ "replace_text",
1239
+ ]