-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdiff_apply.py
More file actions
1549 lines (1373 loc) · 63.6 KB
/
Copy pathdiff_apply.py
File metadata and controls
1549 lines (1373 loc) · 63.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Diff cleaning, validation, and application for asicode.
Handles:
- LLM output cleaning (strip fences, noise, recount hunks)
- git apply with 3-way fallback
- Conflict marker detection and rollback
- Safety guards (large files, binary files, path traversal)
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from common import normalize_rel_path_fast
from config import (
BINARY_SNIFF_BYTES,
CONFLICT_MARKER_MAX_BYTES,
LARGE_FILE_MAX_BYTES,
STRICT_CLEAN,
)
from patch_synth import synthesize_append_line_unified_diff
from path_security import normalize_rel_path
logger = logging.getLogger(__name__)
# =========================================================
# Diff change counting
# =========================================================
# Diff cleaning utilities
# =========================================================
# Accept any fenced code block opener (```diff / ```patch / ```text / ```python ...)
_FENCE_RE = re.compile(r"^\s*```[^\n\r]*\s*$", re.IGNORECASE)
_END_FENCE_RE = re.compile(r"^\s*```\s*$")
_DIFF_START_RE = re.compile(r"^\s*diff\s+--git\s+", re.IGNORECASE)
_HUNK_HEADER_RE = re.compile(r"^\s*@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@")
_PATH_PREFIX_RE = re.compile(r"^(?:a/|b/)")
# Static regex patterns (module-level so they are compiled once, not per call).
# See _extract_files_from_git_apply_output and _has_conflict_markers.
_GIT_APPLY_FILE_PATTERNS = (
re.compile(r"patch failed:\s+(.+?):\d+", re.IGNORECASE),
re.compile(r"Checking patch\s+(.+?)\.\.\.", re.IGNORECASE),
re.compile(r"Applying patch\s+(.+?)\.\.\.", re.IGNORECASE),
re.compile(r"error:\s+(.+?):\s+(No such file|does not exist|cannot|can't|failed)", re.IGNORECASE),
)
def _clean_diff_lines(text: str, strict: bool) -> list[str]:
"""Remove noise from LLM output, returning candidate diff lines."""
if not text:
return []
lines: list[str] = []
in_fence = False
for raw in str(text).splitlines():
ln = raw
# --- strip agent hints / chain hints injected by LLM ---
if ln.startswith("[CHAIN-HINT]") or ln.startswith("[TOOL CHAIN HINT]"):
continue
if ln.startswith("Typical next steps"):
continue
# --- end hint strip ---
# Check end fence first when inside a fence (bare ``` closes it)
if in_fence and _END_FENCE_RE.match(ln):
in_fence = False
continue
# Opening fence (``` with optional language tag like ```diff)
if _FENCE_RE.match(ln):
in_fence = True
continue
# Drop git "index ..." lines (often bogus from LLM)
if ln.startswith("index "):
continue
lines.append(ln)
# Trim empty edges
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
if not lines:
return []
if strict:
has_header = any(_DIFF_START_RE.match(_item_) for _item_ in lines)
has_hunk = any(_HUNK_HEADER_RE.match(_item_) for _item_ in lines)
if not (has_header or has_hunk):
return []
# Skip leading non-diff junk
i = 0
while i < len(lines):
if (
_DIFF_START_RE.match(lines[i])
or _HUNK_HEADER_RE.match(lines[i])
or lines[i].startswith("--- ")
or lines[i].startswith("+++ ")
):
break
i += 1
lines = lines[i:] if i < len(lines) else []
# Truncate trailing non-diff content (strict)
#
# COUNTING-BASED PARSER (Design 11):
# Instead of heuristic noise-budget or regex header checks, use hunk
# line-count validation to determine content boundaries. A line starting
# with "--- " inside a hunk body is hunk content (deleted line), not a
# diff header — the counting approach eliminates this ambiguity.
#
# - Hunk starts with @@ header
# - Inside hunk body: only +, -, space, empty, and \ lines are valid
# - Hunk ends when its claimed line counts are consumed OR a next
# hunk/diff header is encountered
# - Non-diff lines outside hunk are dropped (no noise budget needed)
if strict and lines:
kept: list[str] = []
i = 0
while i < len(lines):
ln = lines[i]
# Diff/file header: always kept
if _is_diff_header_line(ln):
kept.append(ln)
i += 1
continue
# Hunk header: parse and count body
if _HUNK_HEADER_RE.match(ln):
kept.append(ln)
result = _count_hunk_body(lines, i)
if result is not None:
end, actual_old, actual_new, claimed_old, claimed_new = result
# Accept hunk body up to end (may be truncated LLM output)
for j in range(i + 1, end):
kept.append(lines[j])
i = end
else:
i += 1
continue
# ---/+++ file header pair WITHOUT a/b/ prefix (common in LLM output).
# _is_diff_header_line requires "--- a/" or "--- /dev/null", but LLMs
# frequently emit "--- f.py" / "+++ f.py". Detect these as a consecutive
# pair — a lone "--- " line could be an SQL/Lua comment deletion inside
# a hunk, but the hunk-body counter already consumed those before we get
# here (they start with "-"), so pair detection outside hunks is safe.
if ln.startswith("--- ") and not ln.startswith(("--- a/", "--- /dev/null")):
if i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
kept.append(ln)
kept.append(lines[i + 1])
i += 2
else:
i += 1 # lone "--- " without matching "+++ " → skip
continue
# "+++ " without b/ prefix: keep if the preceding kept line is a "--- "
# file header (either canonical "--- a/" or bare "--- f.py" from pair
# detection above). Otherwise skip (standalone "+++ " is not a valid header).
if ln.startswith("+++ ") and not ln.startswith(("+++ b/", "+++ /dev/null")):
if kept and kept[-1].startswith("--- "):
kept.append(ln)
i += 1
continue
# Before first diff/hunk header: skip (non-diff preamble)
if not kept:
i += 1
continue
# After diff/hunk content: trailing noise → skip
i += 1
lines = kept
return lines
def _parse_hunk_header(line: str) -> tuple[int, int, int, int] | None:
"""Parse @@ -a,b +c,d @@ → (old_start, old_count, new_start, new_count)."""
m = re.match(r"^\s*@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@", line)
if not m:
return None
return int(m.group(1)), int(m.group(2) or "1"), int(m.group(3)), int(m.group(4) or "1")
def _is_diff_header_line(ln: str) -> bool:
"""Check if line is a diff/file header (not hunk content).
In standard unified-diff format:
- ``--- a/<path>`` or ``--- /dev/null`` (old file)
- ``+++ b/<path>`` or ``+++ /dev/null`` (new file)
We require ``a/``/``b/`` prefix (or ``/dev/null``) to disambiguate
from hunk content lines starting with ``--- `` (e.g. SQL/Lua comments).
"""
if not ln:
return False
if ln.startswith("diff --git "):
return True
if ln.startswith("--- a/") or ln.startswith("--- /dev/null"):
return True
if ln.startswith("+++ b/") or ln.startswith("+++ /dev/null"):
return True
if ln.startswith(("new file mode ", "deleted file mode ", "old mode ", "new mode ")):
return True
if ln.startswith(("similarity index ", "rename from ", "rename to ")):
return True
return False
def _count_hunk_body(lines: list[str], start: int) -> tuple[int, int, int, int, int] | None:
"""
From a hunk header at lines[start], count actual hunk body lines
and validate against the header's claimed counts.
Returns (end_idx, actual_old, actual_new, claimed_old, claimed_new)
or None if the header can't be parsed.
A hunk body may be shorter than claimed (trailing context can be elided
in LLM output); actual counts are clamped at claimed.
After claimed counts are met, only context lines (`` ``-prefix, ``\\ ``)
continue to be absorbed — blank lines, ``-``/``+`` lines after that
point are considered a new hunk/file boundary. This prevents:
- Markdown bullets (``- ...``) from being absorbed.
- Multi-file bare headers (``--- f2.py``) from being counted as hunk
deletions.
- Blank separator lines between hunks from being counted as phantom
trailing context.
"""
parsed = _parse_hunk_header(lines[start])
if not parsed:
return None
_, claimed_old, _, claimed_new = parsed
i = start + 1
actual_old = 0
actual_new = 0
while i < len(lines):
ln = lines[i]
# Next hunk header or diff header → hunk boundary
if _HUNK_HEADER_RE.match(ln) or _is_diff_header_line(ln):
break
# Bare ---/+++ pair without a/b prefix → file header boundary
# (needed since LLM often omits the a/b prefix).
# Single "--- comment" without matching "+++" is NOT a boundary
# (safe: SQL/Lua comments are consumed as deletion lines if they
# appear inside a hunk body; at hunk boundaries they stay noise).
if ln.startswith("--- ") and not ln.startswith(("--- a/", "--- /dev/null")):
if i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
break # bare file header pair → hunk boundary
# No-newline-at-EOF marker (\) — meta, not counted
if ln.startswith("\\ "):
i += 1
continue
if ln.startswith(" "):
actual_old += 1
actual_new += 1
elif ln.startswith("-"):
actual_old += 1
elif ln.startswith("+"):
actual_new += 1
elif ln == "" or ln.strip() == "":
# Blank/empty lines are valid context lines (git apply counts them).
# Not counting them causes recount to produce headers that misalign
# with git apply's own counting, resulting in "patch does not apply".
actual_old += 1
actual_new += 1
else:
# Non-hunk content → hunk boundary (truncated LLM output)
break
i += 1
# After counts are met, only absorb trailing context lines
# (space-prefixed, \ no-newline). Stop on blank, -/+, or
# anything else: blank lines are separators between hunks/files,
# not legitimate trailing context (legitimate context is always
# space-prefixed in unified diff format).
if (actual_old >= claimed_old and actual_new >= claimed_new) and (
i >= len(lines) or not lines[i].startswith((" ", "\\ "))
):
break
return i, actual_old, actual_new, claimed_old, claimed_new
def _recount_hunks(lines: list[str]) -> list[str]:
"""Recompute line counts for each hunk header using counting-based parser."""
out: list[str] = []
i = 0
while i < len(lines):
ln = lines[i]
if not ln.lstrip().startswith("@@"):
out.append(ln)
i += 1
continue
parsed = _parse_hunk_header(ln)
if not parsed:
out.append(ln)
i += 1
continue
old_a, _, new_a, _ = parsed
result = _count_hunk_body(lines, i)
if result is not None:
end, actual_old, actual_new, _, _ = result
else:
end = i + 1
actual_old = actual_new = 0
out.append(f"@@ -{old_a},{actual_old} +{new_a},{actual_new} @@")
for j in range(i + 1, end):
out.append(lines[j])
i = end
return out
def _rewrite_patch_paths(lines: list[str], target_rel: str) -> list[str]:
"""Enforce patch paths to target file, preserving /dev/null for new files.
Uses counting-based parser to avoid rewriting --- /+++ inside hunk bodies
(e.g., SQL/Lua comment deletions like "--- old comment").
"""
if not target_rel:
return lines
t = normalize_rel_path(target_rel)
out: list[str] = []
i = 0
while i < len(lines):
ln = lines[i]
# Hunk header: use _count_hunk_body to determine hunk extent
if _HUNK_HEADER_RE.match(ln):
out.append(ln)
parsed = _count_hunk_body(lines, i)
if parsed:
end, _, _, _, _ = parsed
# Copy hunk body as-is (no path rewriting inside hunks)
for j in range(i + 1, end):
out.append(lines[j])
i = end
else:
i += 1
# Diff/file headers: rewrite paths
elif ln.startswith("diff --git "):
out.append(f"diff --git a/{t} b/{t}")
i += 1
elif ln.startswith("--- "):
out.append("--- /dev/null" if ln.strip() == "--- /dev/null" else f"--- a/{t}")
i += 1
elif ln.startswith("+++ "):
out.append("+++ /dev/null" if ln.strip() == "+++ /dev/null" else f"+++ b/{t}")
i += 1
else:
out.append(ln)
i += 1
return out
def _upgrade_hunk_fragment(lines: list[str], target_rel: str) -> list[str]:
"""Wrap naked hunk(s) into a minimal unified diff header."""
t = normalize_rel_path(target_rel)
if not t or not lines:
return lines
if any(_item_.startswith("diff --git ") for _item_ in lines):
return lines
if not any(_item_.startswith("@@") for _item_ in lines):
return lines
return [f"diff --git a/{t} b/{t}", f"--- a/{t}", f"+++ b/{t}", *lines]
def _clean_diff(diff_text: str, repo_root: str, file_path_hint: str | None = None) -> str:
"""Full cleaning pipeline: parse, upgrade, rewrite paths, recount."""
lines = _clean_diff_lines(diff_text, strict=STRICT_CLEAN)
if not lines:
return ""
if file_path_hint:
lines = _upgrade_hunk_fragment(lines, file_path_hint)
if file_path_hint and any(_item_.startswith("diff --git ") for _item_ in lines):
lines = _rewrite_patch_paths(lines, file_path_hint)
lines = _recount_hunks(lines)
# IMPORTANT:
# Do NOT use `.rstrip()` here because it strips spaces/tabs from the final
# hunk lines, which can invalidate blank-line entries in hunks and cause:
# git apply --check -> "corrupt patch"
return "\n".join(lines).rstrip("\n") + "\n"
# =========================================================
# Apply failure reason typing
# =========================================================
REASON_OK = "OK"
REASON_CONFLICT = "CONFLICT"
REASON_PATCH_MALFORMED = "PATCH_MALFORMED"
REASON_PATH_INVALID = "PATH_INVALID"
REASON_REPO_NOT_FOUND = "REPO_NOT_FOUND"
REASON_EMPTY_DIFF = "EMPTY_DIFF"
REASON_UNKNOWN = "UNKNOWN"
REASON_CONFLICT_MARKERS = "CONFLICT_MARKERS"
REASON_SKIPPED_LARGE_FILE = "SKIPPED_LARGE_FILE"
REASON_SKIPPED_BINARY_FILE = "SKIPPED_BINARY_FILE"
def _classify_git_apply_output(out: str) -> str:
s = (out or "").lower()
if "corrupt patch" in s or "malformed" in s or "patch fragment" in s or "lacks the necessary blob" in s:
return REASON_PATCH_MALFORMED
if "no such file or directory" in s or "can't find file" in s:
return REASON_PATH_INVALID
if "patch failed" in s or "hunk failed" in s or "does not apply" in s:
return REASON_CONFLICT
return REASON_UNKNOWN
# =========================================================
# Public helpers
# =========================================================
def extract_touched_files_from_diff(diff_text: str) -> list[str]:
"""Parse touched files from unified diff."""
touched: list[str] = []
for ln in (diff_text or "").splitlines():
if ln.startswith("diff --git "):
parts = ln.split()
if len(parts) >= 4:
a_path = _PATH_PREFIX_RE.sub("", parts[2])
b_path = _PATH_PREFIX_RE.sub("", parts[3])
rel = (b_path or a_path).strip().replace("\\", "/").lstrip("/")
if rel and rel not in touched:
touched.append(rel)
return touched
# =========================================================
# Git apply execution
# =========================================================
def _run_git_apply(repo: Path, args: list[str], input_text: str | None = None) -> tuple[int, str]:
cmd = ["git", "apply", *args]
try:
p = subprocess.run(
cmd,
cwd=str(repo),
input=(input_text.encode("utf-8") if input_text is not None else None),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
timeout=30, # Prevent hanging on large patches
)
return p.returncode, p.stdout.decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
return -1, "git apply timeout after 30 seconds"
except Exception as e:
return -1, f"git apply exception: {e}"
def _git_status_porcelain(repo: Path, *, include_untracked: bool = True) -> str:
"""Return `git status --porcelain` output (best-effort)."""
try:
cmd = ["git", "status", "--porcelain"]
if not include_untracked:
cmd.append("--untracked-files=no")
p = subprocess.run(
cmd,
cwd=str(repo),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
timeout=30, # bound HTTP request; TimeoutExpired caught below
)
return (p.stdout or b"").decode("utf-8", errors="replace").strip()
except subprocess.TimeoutExpired:
# Distinguish a hung git from a genuinely clean tree: returning "" makes
# _is_worktree_clean() report True, so a timeout would silently masquerade
# as "no changes" and let _rollback conclude there is nothing to restore.
logger.warning(
"git status timed out (repo=%s); returning empty — rollback/clean "
"checks may misjudge a timeout as 'no changes'",
repo,
)
return ""
except Exception:
return ""
def _is_worktree_clean(repo: Path) -> bool:
"""True if there are no uncommitted *tracked* changes (best-effort).
NOTE:
- Untracked files (??) are ignored, because they don't affect `git apply --3way`
safety for tracked file merges, and blocking on them hurts UX.
"""
return _git_status_porcelain(repo, include_untracked=False).strip() == ""
def _cleanup_reject_files(repo: Path, touched_files: list[str]) -> None:
"""Clean up .rej and .orig files for touched files only (no global rglob)."""
for rel in touched_files:
p = repo / rel
for suffix in (".rej", ".orig"):
try:
candidate = Path(str(p) + suffix)
if candidate.exists():
candidate.unlink()
except Exception as e:
logger.debug("Failed to clean %s: %s", candidate, e)
def _extract_files_from_git_apply_output(stderr_text: str) -> list[str]:
"""Best-effort extraction of file paths from git apply output."""
if not stderr_text:
return []
files: set[str] = set()
for ln in stderr_text.splitlines():
s = ln.strip()
for pat in _GIT_APPLY_FILE_PATTERNS:
m = pat.search(s)
if m:
files.add(m.group(1).strip())
break
normed: set[str] = set()
for f in files:
f = f.strip().strip('"').strip("'")
if f.startswith("a/") or f.startswith("b/"):
f = f[2:]
f = f.rstrip(".,;:")
if f and f not in ("dev/null", "/dev/null"):
normed.add(f)
return sorted(normed)
def _is_probably_binary_file(path: Path, sniff_bytes: int) -> bool:
try:
with open(path, "rb") as f:
b = f.read(max(1, sniff_bytes))
return b"\x00" in b
except Exception:
return False
def _has_conflict_markers(path: Path, max_bytes: int) -> bool:
"""
Detect git conflict markers (<<<<<<<, =======, >>>>>>>).
Requires a complete conflict block (open → separator → close, in order)
to avoid false positives from Markdown setext headings (=======) or RST
dividers.
Uses ALL match positions per marker (not just the first) so that a stray
separator-like line (e.g. a setext '=======' heading) appearing BEFORE a
real conflict block does not cause a false negative: the first-match
approach would pick the heading as the separator, fail the open<sep<close
ordering, and miss the genuine conflict block that follows.
"""
try:
size = path.stat().st_size
to_read = min(size, max_bytes) if max_bytes > 0 else size
with open(path, "rb") as f:
b = f.read(to_read)
text = b.decode("utf-8", errors="ignore")
# Collect line-anchored start positions per marker type (ascending).
opens = [m.start() for m in re.finditer(r"^<{7}", text, re.MULTILINE)]
if not opens:
return False
seps = [m.start() for m in re.finditer(r"^={7}", text, re.MULTILINE)]
closes = [m.start() for m in re.finditer(r"^>{7}", text, re.MULTILINE)]
if not seps or not closes:
return False
# A real conflict block exists iff some open < separator < close triple
# can be formed. Positions are ascending, so the first separator after
# each open and the first close after that separator settle it.
for o in opens:
s = next((x for x in seps if x > o), None)
if s is None:
continue
if any(c > s for c in closes):
return True
return False
except Exception:
return False
def _resolve_inside_repo_path(repo: Path, rel: str) -> Path:
"""Resolve repo/rel and ensure result stays inside repo."""
base = repo.resolve()
p = (base / rel).resolve()
try:
p.relative_to(base)
except ValueError:
raise ValueError(f"path_outside_repo: {rel}")
return p
# =========================================================
# 3-way fallback logic (deduplicated)
# =========================================================
def _git_status_untracked(repo: Path) -> set[str]:
"""
Return current untracked file paths (relative, normalized with '/').
Uses porcelain -z for robustness.
"""
try:
p = subprocess.run(
["git", "status", "--porcelain", "-z"],
cwd=str(repo),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
timeout=30, # bound HTTP request; TimeoutExpired caught below
)
out = (p.stdout or b"").decode("utf-8", errors="replace")
items = [x for x in out.split("\x00") if x]
untracked: set[str] = set()
i = 0
while i < len(items):
rec = items[i]
if len(rec) >= 3 and rec[:2] == "??" and rec[2] == " ":
path = rec[3:].strip().replace("\\", "/").lstrip("/")
if path:
untracked.add(path)
i += 1
continue
# Renames in -z have: "R old\0new\0" (or similar). Skip the extra path if present.
if len(rec) >= 3 and rec[2] == " " and rec[:2].strip() and rec[:2] != "??":
# If it's a rename/copy, git emits an additional path item.
if rec[0] in ("R", "C") or rec[1] in ("R", "C"):
i += 2
else:
i += 1
continue
i += 1
return untracked
except subprocess.TimeoutExpired:
# An empty set means "nothing was pre-existing untracked", so on a timeout
# _rollback could wrongly delete files it considers newly-created. Log so a
# hung git is observable instead of silently degrading rollback safety.
logger.warning(
"git status -z timed out (repo=%s); returning empty set — rollback "
"snapshot may be incomplete (nothing recorded as pre-existing untracked)",
repo,
)
return set()
except Exception:
return set()
def _capture_rollback_snapshot(repo: Path, touched_files: list[str]) -> dict[str, Any]:
"""
Capture enough info to rollback *completely*:
- untracked files before apply attempt (so we don't delete user-owned untracked files)
- existence of touched paths before apply (so we can delete newly-created touched files/dirs)
"""
pre_untracked = _git_status_untracked(repo)
pre_exists: dict[str, bool] = {}
for rel in (touched_files or []):
try:
p = _resolve_inside_repo_path(repo, rel)
pre_exists[rel] = p.exists()
except Exception:
# touched_files are already path-safety filtered before snapshot is used
pre_exists[rel] = False
return {"pre_untracked": pre_untracked, "pre_exists": pre_exists}
def _delete_path_best_effort(p: Path) -> None:
try:
if p.is_symlink() or p.is_file():
p.unlink(missing_ok=True) # py>=3.8
elif p.is_dir():
# remove dir tree
for child in sorted(p.rglob("*"), reverse=True):
try:
if child.is_symlink() or child.is_file():
child.unlink(missing_ok=True)
elif child.is_dir():
child.rmdir()
except Exception:
pass
try:
p.rmdir()
except Exception:
pass
except Exception:
pass
def _parse_porcelain_z_paths(raw: str) -> list[str]:
"""Parse ``git status --porcelain -z`` output into a list of dirty file paths.
Handles non-ASCII filenames (no C-quoting with -z) and rename entries
(``R old\\0new\\0`` where the extra path must be skipped).
"""
items = [x for x in (raw or "").split("\x00") if x]
paths: list[str] = []
i = 0
while i < len(items):
rec = items[i]
if len(rec) >= 4 and rec[2] == " ":
path = rec[3:]
if path:
paths.append(path)
# Renames/copies have an additional path entry in the next item
if rec[0] in ("R", "C") or rec[1] in ("R", "C"):
i += 2
else:
i += 1
else:
i += 1
return paths
def _rollback(repo: Path, touched_files: list[str], snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Best-effort *complete* rollback:
- restore tracked changes for touched files (only pre-existing ones)
- delete newly-created touched files/dirs that didn't exist before
- delete newly-created untracked files that appeared during apply attempt
(without touching pre-existing untracked files)
Returns a report dict with verification results.
"""
report: dict[str, Any] = {
"attempted": False,
"verified": False,
"restore_failed": [],
"delete_failed": [],
"remaining_dirty": [],
}
pre_exists = (snapshot or {}).get("pre_exists") or {}
try:
# 1) Restore tracked state for pre-existing touched files only
# New files (pre_exists[rel] is False) would cause git to abort with
# "pathspec did not match", leaving ALL files unrestored.
if touched_files:
existing_files = [rel for rel in touched_files if pre_exists.get(rel) is not False]
if existing_files:
report["attempted"] = True
result = subprocess.run(
["git", "restore", "--staged", "--worktree", "--", *existing_files],
cwd=str(repo),
check=False,
capture_output=True,
text=True,
timeout=30, # bound HTTP request; TimeoutExpired caught below
)
if result.returncode != 0:
logger.warning("git restore failed (rc=%d): %s", result.returncode, result.stderr)
report["restore_failed"] = existing_files
except Exception as e:
logger.warning("Rollback restore failed: %s", e)
# 2) Delete newly-created paths among touched files (covers new files and dirs)
try:
for rel in (touched_files or []):
try:
if pre_exists.get(rel) is False:
p = _resolve_inside_repo_path(repo, rel)
if p.exists():
report["attempted"] = True
_delete_path_best_effort(p)
except Exception:
continue
except Exception as e:
logger.warning("Rollback delete-new-touched failed: %s", e)
# 3) Delete newly-created untracked files (delta from pre_untracked)
try:
pre_untracked: set[str] = set((snapshot or {}).get("pre_untracked") or set())
post_untracked = _git_status_untracked(repo)
created = sorted(p for p in (post_untracked - pre_untracked) if p)
if created:
report["attempted"] = True
# Use git clean with explicit pathspecs (safer than global clean)
# Batch to avoid argv limits
BATCH = 50
for i in range(0, len(created), BATCH):
batch = created[i : i + BATCH]
subprocess.run(
["git", "clean", "-fd", "--", *batch],
cwd=str(repo),
check=False,
timeout=30, # bound HTTP request; TimeoutExpired caught below
)
# Extra safety: best-effort delete if git clean didn't remove something
for rel in created:
try:
p = _resolve_inside_repo_path(repo, rel)
if p.exists():
_delete_path_best_effort(p)
except Exception:
pass
except Exception as e:
logger.warning("Rollback clean-new-untracked failed: %s", e)
# 4) Verification: check if touched files are actually clean
# Use -z (NUL-separated) to robustly handle non-ASCII filenames (Korean, CJK)
# and rename entries — plain --porcelain C-quotes non-ASCII paths and
# renames use "R old -> new" which breaks naive line[3:] parsing.
if report["attempted"] and touched_files:
try:
result = subprocess.run(
["git", "status", "--porcelain", "-z", "--", *touched_files],
cwd=str(repo),
check=False,
capture_output=True,
text=False, # bytes for safe NUL-split
timeout=30, # bound HTTP request; TimeoutExpired caught below
)
if result.returncode == 0:
raw = (result.stdout or b"").decode("utf-8", errors="replace")
dirty_paths = _parse_porcelain_z_paths(raw)
if dirty_paths:
report["remaining_dirty"] = dirty_paths
report["verified"] = False
else:
report["verified"] = True
else:
logger.warning("git status verification failed (rc=%d): %s",
result.returncode,
(result.stderr or b"").decode("utf-8", errors="replace"))
except Exception as e:
logger.warning("Rollback verification failed: %s", e)
return report
def _rollback_warning(report: dict[str, Any]) -> str:
"""Return a user-facing warning suffix if rollback left dirty state."""
if not report.get("attempted"):
return ""
if report.get("verified"):
return ""
dirty = report.get("remaining_dirty", [])
if dirty:
return f" [WARNING: repository was left in a dirty state. Remaining files: {len(dirty)}]"
return " [WARNING: repository was left in a dirty state (unable to verify)]"
def _try_3way_fallback(
repo: Path,
cleaned: str,
touched_files: list[str],
snapshot: dict[str, Any] | None = None,
) -> tuple[bool, str, str, dict[str, Any]]:
"""
Attempt git apply --3way. If it succeeds but leaves conflict markers, roll back.
Returns (ok, msg, reason, details).
"""
rc3, out3 = _run_git_apply(
repo, ["--3way", "--recount", "--whitespace=nowarn"], input_text=cleaned,
)
if rc3 != 0:
return False, out3.strip() or "3way also failed", _classify_git_apply_output(out3), {
"touched_files": touched_files,
"used_strategy": "git-apply-3way-failed",
"returncode": rc3,
}
# Check for conflict markers
marker_hits: list[str] = []
for rel in touched_files:
fp = repo / rel
if fp.exists() and fp.is_file() and _has_conflict_markers(fp, CONFLICT_MARKER_MAX_BYTES):
marker_hits.append(rel)
if marker_hits:
rollback_report = _rollback(repo, touched_files, snapshot=snapshot)
_cleanup_reject_files(repo, touched_files)
msg = f"3way produced conflict markers in: {', '.join(marker_hits[:8])}"
return False, msg, REASON_CONFLICT_MARKERS, {
"touched_files": touched_files,
"failed_files": sorted(set(touched_files)),
"rollback_performed": rollback_report["attempted"],
"rollback_verified": rollback_report["verified"],
"rollback_remaining_dirty": rollback_report["remaining_dirty"],
"used_strategy": "git-apply-3way-marker-guard",
"marker_files": marker_hits,
}
return True, "applied", REASON_OK, {
"touched_files": touched_files,
"failed_files": [],
"rollback_performed": False,
"used_strategy": "git-apply-3way",
}
# =========================================================
# Patch apply (main entry point)
# =========================================================
def apply_patch(
repo_root: str, diff_text: str, file_path_hint: str | None = None,
skip_3way: bool = False,
) -> tuple[bool, str, str, dict[str, Any]]:
"""
Apply a unified diff patch to repo_root.
- Preflight skip for large/binary files
- git apply with --3way fallback on conflicts
- Conflict marker detection and rollback
Args:
skip_3way: When True, do NOT attempt `git apply --3way` on conflict. Set this for
targets known to lack a pre-image blob (untracked / gitignored / freshly-edited
files) — `--3way` would fail with "repository lacks the necessary blob to
perform a 3-way merge" anyway, so skipping it avoids a wasted subprocess and
surfaces the real conflict error directly. Plain `git apply` (non-3way) is
still attempted, so well-formed patches on such files still succeed.
"""
repo = Path(repo_root).resolve()
execution_steps: list[dict[str, Any]] = []
if not repo.exists():
msg = f"repo_root not found: {repo}"
return False, msg, REASON_REPO_NOT_FOUND, {"last_error": msg, "execution_steps": execution_steps}
raw = "" if diff_text is None else str(diff_text)
# Detect and wrap hunk‑only diffs (no file headers)
lines = raw.strip().splitlines()
first_non_empty = None
for line in lines:
if line.strip():
first_non_empty = line
break
is_hunk_only = (
first_non_empty is not None
and first_non_empty.startswith("@@")
and "diff --git" not in raw
and "--- a/" not in raw
and "+++ b/" not in raw
)
if is_hunk_only:
if not file_path_hint:
msg = "Hunk‑only patch requires the 'path' parameter to determine the target file"
return False, msg, "MISSING_PATH_HINT", {"execution_steps": execution_steps}
# Normalize path: ensure it's relative and inside repo_root
norm = normalize_rel_path(file_path_hint)
if not norm:
msg = f"Invalid or unsafe path in hunk‑only patch: {file_path_hint}"
return False, msg, "PATH_INVALID", {"execution_steps": execution_steps}
# Construct full unified diff headers
header = f"diff --git a/{norm} b/{norm}\n--- a/{norm}\n+++ b/{norm}\n"
raw = header + raw
cleaned = _clean_diff(raw, repo_root, file_path_hint=file_path_hint)
if not cleaned.strip():
return False, "empty diff after cleaning", REASON_EMPTY_DIFF, {"execution_steps": execution_steps}
touched_files = extract_touched_files_from_diff(cleaned)
# Guard: path safety
safe_touched: list[str] = []
unsafe: list[str] = []
for rel in touched_files:
norm = normalize_rel_path(rel)
if not norm:
unsafe.append(rel)
continue
try:
_resolve_inside_repo_path(repo, norm)
safe_touched.append(norm)
except Exception:
unsafe.append(rel)
touched_files = safe_touched
if unsafe:
msg = f"unsafe path in patch: {', '.join(unsafe[:8])}"
return False, msg, REASON_PATH_INVALID, {"unsafe_paths": unsafe, "execution_steps": execution_steps}
# Guard: large / binary files
for rel in touched_files:
fp = _resolve_inside_repo_path(repo, rel)
if not fp.exists():
continue
if fp.is_file() and fp.stat().st_size > LARGE_FILE_MAX_BYTES:
msg = f"skipped: large file {rel} exceeds {LARGE_FILE_MAX_BYTES} bytes"
return False, msg, REASON_SKIPPED_LARGE_FILE, {"skipped_large": [rel], "execution_steps": execution_steps}