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,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
|
+
]
|