1use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum PatchLine {
12 Context(String),
13 Remove(String),
14 Add(String),
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Hunk {
19 pub old_start: usize,
20 pub old_count: usize,
21 pub new_start: usize,
22 pub new_count: usize,
23 pub lines: Vec<PatchLine>,
24 pub old_no_final_newline: bool,
26 pub new_no_final_newline: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PatchFile {
32 pub path: String,
33 pub hunks: Vec<Hunk>,
34 pub is_creation: bool,
36 pub is_deletion: bool,
38 pub rename_from: Option<String>,
41 pub copy_from: Option<String>,
44 pub unsupported: Option<String>,
47}
48
49impl PatchFile {
50 fn with_path(path: String) -> Self {
51 Self {
52 path,
53 hunks: Vec::new(),
54 is_creation: false,
55 is_deletion: false,
56 rename_from: None,
57 copy_from: None,
58 unsupported: None,
59 }
60 }
61
62 #[must_use]
66 pub fn uses_entry_containment(&self) -> bool {
67 self.rename_from.is_some() || (self.is_deletion && self.hunks.is_empty())
68 }
69
70 #[must_use]
72 pub fn declared_paths(&self) -> Vec<String> {
73 let mut paths = vec![self.path.clone()];
74 if let Some(from) = self.rename_from.as_ref() {
75 paths.push(from.clone());
76 }
77 if let Some(from) = self.copy_from.as_ref() {
78 paths.push(from.clone());
79 }
80 paths
81 }
82}
83
84pub fn rename_would_clobber_dest(from: &str, to: &str, dest_exists: bool) -> bool {
89 if !dest_exists || from == to {
90 return false;
91 }
92 let from_path = std::path::Path::new(from);
93 let to_path = std::path::Path::new(to);
94 let case_only = from_path.parent() == to_path.parent()
95 && from_path.file_name().map(|n| n.to_ascii_lowercase())
96 == to_path.file_name().map(|n| n.to_ascii_lowercase());
97 !case_only
98}
99
100pub fn rename_dest_exists_msg(to: &str) -> String {
102 format!(
103 "destination already exists: {to} (patch rename refuses overwrite; remove dest or use file.rename --force)"
104 )
105}
106
107pub fn copy_dest_exists_msg(to: &str) -> String {
109 format!("destination already exists: {to} (patch copy refuses overwrite; remove dest)")
110}
111
112pub fn create_dest_exists_msg(to: &str) -> String {
114 format!("destination already exists: {to} (patch create refuses overwrite; remove dest)")
115}
116
117pub(crate) fn dest_clobber_msg(
123 path: &str,
124 dest_exists: bool,
125 rename_from: Option<&str>,
126 is_copy: bool,
127 is_creation: bool,
128) -> Option<String> {
129 if let Some(from) = rename_from
130 && rename_would_clobber_dest(from, path, dest_exists)
131 {
132 return Some(rename_dest_exists_msg(path));
133 }
134 if is_copy && dest_exists {
135 return Some(copy_dest_exists_msg(path));
136 }
137 if is_creation && dest_exists {
138 return Some(create_dest_exists_msg(path));
139 }
140 None
141}
142
143impl PatchFile {
144 pub(crate) fn dest_clobber_msg(&self, dest_exists: bool) -> Option<String> {
145 dest_clobber_msg(
146 &self.path,
147 dest_exists,
148 self.rename_from.as_deref(),
149 self.copy_from.is_some(),
150 self.is_creation && self.copy_from.is_none(),
151 )
152 }
153}
154
155pub(crate) fn staged_path_exists(
157 path: &Path,
158 created: &HashSet<PathBuf>,
159 deleted: &HashSet<PathBuf>,
160) -> bool {
161 if created.contains(path) {
162 return true;
163 }
164 if deleted.contains(path) {
165 return false;
166 }
167 crate::ops::file::path_entry_exists(path)
168}
169
170pub(crate) fn record_staged_patch_dest(
172 cwd: &Path,
173 pf: &PatchFile,
174 created: &mut HashSet<PathBuf>,
175 deleted: &mut HashSet<PathBuf>,
176) {
177 let dest = cwd.join(&pf.path);
178 if pf.is_deletion {
179 created.remove(&dest);
180 deleted.insert(dest);
181 return;
182 }
183 if let Some(from) = pf.rename_from.as_ref() {
184 let from_abs = cwd.join(from);
185 created.remove(&from_abs);
186 deleted.insert(from_abs);
187 }
188 deleted.remove(&dest);
190 created.insert(dest);
191}
192
193pub fn unsupported_git_meta_msg(path: &str, reason: &str) -> String {
195 format!(
196 "patch apply: {path} -- unsupported git-meta ({reason}); dest is listed for preflight but apply refuses"
197 )
198}
199
200fn is_file_header(lines: &[&str], idx: usize) -> bool {
213 if !lines[idx].starts_with("--- ")
214 || idx + 1 >= lines.len()
215 || !lines[idx + 1].starts_with("+++ ")
216 {
217 return false;
218 }
219 let minus_rest = &lines[idx][4..];
220 let plus_rest = &lines[idx + 1][4..];
221 if minus_rest.starts_with("a/")
223 || minus_rest.starts_with("a\\")
224 || minus_rest.starts_with("/dev/null")
225 {
226 return true;
227 }
228 if plus_rest.starts_with("b/")
229 || plus_rest.starts_with("b\\")
230 || plus_rest.starts_with("/dev/null")
231 {
232 return true;
233 }
234 if minus_rest.contains('\t') || plus_rest.contains('\t') {
236 return true;
237 }
238 if idx > 0 && lines[idx - 1].starts_with("diff ") {
240 return true;
241 }
242 idx == 0
244}
245
246fn unquote_git_path_meta(s: &str) -> String {
248 let s = s.trim();
249 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
250 unquote_git_c_string(&s[1..s.len() - 1])
251 } else {
252 s.to_string()
253 }
254}
255
256#[must_use]
262pub fn parse_diff_git_paths(line: &str) -> Option<(String, String)> {
263 let rest = line.strip_prefix("diff --git ").unwrap_or(line).trim();
264 if rest.is_empty() {
265 return None;
266 }
267 let (raw_a, raw_b) = split_two_git_path_tokens(rest)?;
268 let a = strip_diff_ab_prefix(&unquote_git_c_string(&raw_a))?;
271 let b = strip_diff_ab_prefix(&unquote_git_c_string(&raw_b))?;
272 if a.is_empty() || b.is_empty() {
273 return None;
274 }
275 Some((a, b))
276}
277
278fn strip_diff_ab_prefix(s: &str) -> Option<String> {
279 s.strip_prefix("a/")
280 .or_else(|| s.strip_prefix("b/"))
281 .or_else(|| s.strip_prefix("a\\"))
282 .or_else(|| s.strip_prefix("b\\"))
283 .map(str::to_string)
284}
285
286#[must_use]
289pub(crate) fn line_looks_like_unified_file_header(line: &str) -> bool {
290 let t = line.trim_start();
291 if t.starts_with("diff --git ") {
292 return true;
293 }
294 t.strip_prefix("--- ")
295 .or_else(|| t.strip_prefix("+++ "))
296 .is_some_and(|path| strip_diff_ab_prefix(path).is_some())
297}
298
299fn split_two_git_path_tokens(rest: &str) -> Option<(String, String)> {
300 let mut chars = rest.chars().peekable();
301 let first = next_git_path_token(&mut chars)?;
302 let second = next_git_path_token(&mut chars)?;
303 Some((first, second))
304}
305
306fn next_git_path_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
307 while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
308 chars.next();
309 }
310 match chars.peek() {
311 None => None,
312 Some('"') => {
313 chars.next();
314 let mut raw = String::new();
315 while let Some(c) = chars.next() {
316 if c == '\\' {
317 raw.push('\\');
318 if let Some(n) = chars.next() {
319 raw.push(n);
320 }
321 } else if c == '"' {
322 break;
323 } else {
324 raw.push(c);
325 }
326 }
327 Some(raw)
328 }
329 Some(_) => {
330 let mut raw = String::new();
331 while let Some(&c) = chars.peek() {
332 if c.is_whitespace() {
333 break;
334 }
335 raw.push(c);
336 chars.next();
337 }
338 if raw.is_empty() { None } else { Some(raw) }
339 }
340 }
341}
342
343pub fn parse_patch(input: &str) -> Result<Vec<PatchFile>, String> {
344 let lines: Vec<&str> = input.lines().collect();
345 let mut files: Vec<PatchFile> = Vec::new();
346 let mut i = 0;
347 let mut suppress_path_rename = false;
350 let mut pending_copy_from: Option<String> = None;
353
354 while i < lines.len() {
355 if lines[i].starts_with("diff --git ")
361 && (i + 1 >= lines.len() || !is_file_header(&lines, i + 1))
362 {
363 let mut rename_from_meta: Option<String> = None;
365 let mut rename_to_meta: Option<String> = None;
366 let mut copy_from_meta: Option<String> = None;
367 let mut copy_to_meta: Option<String> = None;
368 let mut saw_copy = false;
369 let mut saw_new_file_mode = false;
370 let mut saw_deleted_file_mode = false;
371 let mut saw_empty_index = false;
372 let mut saw_git_binary = false;
373 let mut saw_binary_files = false;
374 let mut saw_old_mode = false;
375 let mut saw_new_mode = false;
376 let mut j = i + 1;
377 while j < lines.len() && !lines[j].starts_with("diff ") && !is_file_header(&lines, j) {
378 if let Some(rest) = lines[j].strip_prefix("rename from ") {
379 rename_from_meta = Some(unquote_git_path_meta(rest));
381 } else if let Some(rest) = lines[j].strip_prefix("rename to ") {
382 rename_to_meta = Some(unquote_git_path_meta(rest));
383 } else if let Some(rest) = lines[j].strip_prefix("copy from ") {
384 copy_from_meta = Some(unquote_git_path_meta(rest));
385 saw_copy = true;
386 } else if let Some(rest) = lines[j].strip_prefix("copy to ") {
387 copy_to_meta = Some(unquote_git_path_meta(rest));
388 saw_copy = true;
389 } else if lines[j].starts_with("new file mode ") {
390 saw_new_file_mode = true;
391 } else if lines[j].starts_with("deleted file mode ") {
392 saw_deleted_file_mode = true;
393 } else if lines[j].starts_with("old mode ") {
394 saw_old_mode = true;
395 } else if lines[j].starts_with("new mode ") {
396 saw_new_mode = true;
397 } else if lines[j].starts_with("GIT binary patch") {
398 saw_git_binary = true;
399 } else if lines[j].starts_with("Binary files ") && lines[j].contains(" differ") {
400 saw_binary_files = true;
401 } else if let Some(rest) = lines[j].strip_prefix("index ") {
402 if rest.contains("0000000..e69de29") || rest.starts_with("0000000..") {
404 saw_empty_index = true;
405 }
406 } else if lines[j].starts_with("@@ ") {
407 break;
410 }
411 j += 1;
412 }
413 if j < lines.len() && is_file_header(&lines, j) {
416 suppress_path_rename = saw_copy;
417 if saw_copy {
418 pending_copy_from = copy_from_meta.clone();
419 }
420 i = j;
421 } else if let (Some(from), Some(to)) = (rename_from_meta, rename_to_meta) {
423 let mut pf = PatchFile::with_path(to);
427 pf.rename_from = Some(from);
428 files.push(pf);
429 i = j;
430 continue;
431 } else if let (Some(from), Some(to)) = (copy_from_meta, copy_to_meta) {
432 let mut pf = PatchFile::with_path(to);
434 pf.is_creation = true;
435 pf.copy_from = Some(from);
436 files.push(pf);
437 i = j;
438 continue;
439 } else if saw_git_binary || saw_binary_files {
440 let dest = parse_diff_git_paths(lines[i]).map(|(_, b)| b);
441 let Some(dest) = dest else {
442 return Err("binary git-meta dest could not be parsed".to_string());
443 };
444 let reason = if saw_git_binary {
445 "GIT binary patch"
446 } else {
447 "Binary files differ"
448 };
449 let mut pf = PatchFile::with_path(dest);
450 pf.unsupported = Some(reason.to_string());
451 files.push(pf);
452 i = j;
453 continue;
454 } else if saw_new_file_mode && saw_empty_index {
455 let dest = parse_diff_git_paths(lines[i]).map(|(_, b)| b);
456 let Some(dest) = dest else {
457 return Err("empty-create dest could not be parsed".to_string());
458 };
459 let mut pf = PatchFile::with_path(dest);
460 pf.is_creation = true;
461 files.push(pf);
462 i = j;
463 continue;
464 } else if saw_deleted_file_mode {
465 let dest = parse_diff_git_paths(lines[i]).map(|(a, _)| a);
466 let Some(dest) = dest else {
467 return Err("deleted-file dest could not be parsed".to_string());
468 };
469 let mut pf = PatchFile::with_path(dest);
470 pf.is_deletion = true;
471 files.push(pf);
472 i = j;
473 continue;
474 } else if saw_old_mode && saw_new_mode {
475 let dest = parse_diff_git_paths(lines[i]).map(|(_, b)| b);
476 let Some(dest) = dest else {
477 return Err("mode-only dest could not be parsed".to_string());
478 };
479 let mut pf = PatchFile::with_path(dest);
480 pf.unsupported = Some("mode-only chmod".to_string());
481 files.push(pf);
482 i = j;
483 continue;
484 } else {
485 i += 1;
487 continue;
488 }
489 }
490
491 if !is_file_header(&lines, i) {
492 i += 1;
493 continue;
494 }
495
496 let minus_path = parse_file_path(lines[i]);
499 let plus_path = parse_file_path(lines[i + 1]);
500 let is_creation = minus_path == "/dev/null";
501 let is_deletion = plus_path == "/dev/null";
502 let rename_from =
503 if !is_creation && !is_deletion && minus_path != plus_path && !suppress_path_rename {
504 Some(minus_path.clone())
505 } else {
506 None
507 };
508 suppress_path_rename = false;
509 let path = if is_deletion { minus_path } else { plus_path };
510 i += 2;
511
512 let mut hunks: Vec<Hunk> = Vec::new();
513 while i < lines.len() && !is_file_header(&lines, i) {
514 if lines[i].starts_with("@@ ") {
515 let hunk = parse_hunk_header(lines[i])?;
516 let mut hunk_lines: Vec<PatchLine> = Vec::new();
517 let mut old_no_final_newline = false;
518 let mut new_no_final_newline = false;
519 let mut remaining_old = hunk.old_count;
520 let mut remaining_new = hunk.new_count;
521 i += 1;
522
523 while i < lines.len()
524 && !lines[i].starts_with("@@ ")
525 && !is_file_header(&lines, i)
526 && !lines[i].starts_with("diff ")
527 {
528 if remaining_old == 0 && remaining_new == 0 {
529 break;
530 }
531 let line = lines[i];
532 if let Some(rest) = line.strip_prefix('+') {
533 hunk_lines.push(PatchLine::Add(rest.to_string()));
534 remaining_new = remaining_new.saturating_sub(1);
535 } else if let Some(rest) = line.strip_prefix('-') {
536 hunk_lines.push(PatchLine::Remove(rest.to_string()));
537 remaining_old = remaining_old.saturating_sub(1);
538 } else if let Some(rest) = line.strip_prefix(' ') {
539 hunk_lines.push(PatchLine::Context(rest.to_string()));
540 remaining_old = remaining_old.saturating_sub(1);
541 remaining_new = remaining_new.saturating_sub(1);
542 } else if line == "\\ No newline at end of file" {
543 match hunk_lines.last() {
545 Some(PatchLine::Add(_)) => new_no_final_newline = true,
546 Some(PatchLine::Remove(_) | PatchLine::Context(_)) => {
547 old_no_final_newline = true;
548 }
549 None => {}
550 }
551 } else {
552 hunk_lines.push(PatchLine::Context(line.to_string()));
553 remaining_old = remaining_old.saturating_sub(1);
554 remaining_new = remaining_new.saturating_sub(1);
555 }
556 i += 1;
557 }
558
559 if remaining_old == 0 && remaining_new == 0 {
560 while i < lines.len() && lines[i] == "\\ No newline at end of file" {
561 match hunk_lines.last() {
562 Some(PatchLine::Add(_)) => new_no_final_newline = true,
563 Some(PatchLine::Remove(_) | PatchLine::Context(_)) => {
564 old_no_final_newline = true;
565 }
566 None => {}
567 }
568 i += 1;
569 }
570 } else {
571 return Err(format!(
572 "incomplete hunk for {path}: remaining -{remaining_old} +{remaining_new}"
573 ));
574 }
575
576 hunks.push(Hunk {
577 old_start: hunk.old_start,
578 old_count: hunk.old_count,
579 new_start: hunk.new_start,
580 new_count: hunk.new_count,
581 lines: hunk_lines,
582 old_no_final_newline,
583 new_no_final_newline,
584 });
585 } else if lines[i].starts_with("diff ") {
586 break;
587 } else {
588 i += 1;
589 }
590 }
591
592 if hunks.is_empty() && rename_from.is_none() && !is_creation && !is_deletion {
595 return Err(format!("no hunks found for file {path}"));
596 }
597 if hunks.is_empty() && is_creation {
598 return Err(format!("no hunks found for file {path}"));
599 }
600 files.push(PatchFile {
603 path,
604 hunks,
605 is_creation,
606 is_deletion,
607 rename_from,
608 copy_from: pending_copy_from.take(),
609 unsupported: None,
610 });
611 }
612
613 if files.is_empty() {
614 return Err("no files found in patch".to_string());
615 }
616
617 Ok(files)
618}
619
620fn parse_file_path(line: &str) -> String {
621 let raw = line
622 .strip_prefix("+++ ")
623 .or_else(|| line.strip_prefix("--- "))
624 .unwrap_or(line);
625
626 let mut path = raw.split('\t').next().unwrap_or(raw);
629
630 let owned_unquoted;
633 if path.len() >= 2 && path.starts_with('"') && path.ends_with('"') {
634 owned_unquoted = unquote_git_c_string(&path[1..path.len() - 1]);
635 path = owned_unquoted.as_str();
636 }
637
638 let path = path
639 .strip_prefix("b/")
640 .or_else(|| path.strip_prefix("a/"))
641 .or_else(|| path.strip_prefix("b\\"))
642 .or_else(|| path.strip_prefix("a\\"))
643 .unwrap_or(path);
644 path.to_string()
645}
646
647pub fn patch_declared_paths(text: &str) -> Result<Vec<String>, String> {
653 let files = parse_patch(text)?;
654 Ok(files.iter().flat_map(PatchFile::declared_paths).collect())
655}
656
657#[must_use]
662pub fn parse_diff_file_path(token: &str) -> String {
663 let t = token.trim();
664 let t = t
665 .strip_prefix("+++ ")
666 .or_else(|| t.strip_prefix("--- "))
667 .or_else(|| t.strip_prefix("rename from "))
668 .or_else(|| t.strip_prefix("rename to "))
669 .or_else(|| t.strip_prefix("copy from "))
670 .or_else(|| t.strip_prefix("copy to "))
671 .unwrap_or(t);
672 parse_file_path(&format!("+++ {t}"))
673}
674
675#[must_use]
681pub fn unquote_git_c_string(s: &str) -> String {
682 let mut bytes = Vec::with_capacity(s.len());
683 let mut chars = s.chars().peekable();
684 while let Some(c) = chars.next() {
685 if c == '\\' {
686 match chars.next() {
687 Some('a') => bytes.push(b'\x07'),
688 Some('b') => bytes.push(b'\x08'),
689 Some('f') => bytes.push(b'\x0c'),
690 Some('n') => bytes.push(b'\n'),
691 Some('r') => bytes.push(b'\r'),
692 Some('t') => bytes.push(b'\t'),
693 Some('v') => bytes.push(b'\x0b'),
694 Some('"') => bytes.push(b'"'),
695 Some('\\') => bytes.push(b'\\'),
696 Some(d) if d.is_digit(8) => {
697 let mut val = d.to_digit(8).unwrap_or(0);
699 for _ in 0..2 {
700 match chars.peek().and_then(|n| n.to_digit(8)) {
701 Some(nd) => {
702 chars.next();
703 val = val * 8 + nd;
704 }
705 None => break,
706 }
707 }
708 bytes.push(val as u8);
709 }
710 Some(other) => {
711 let mut buf = [0u8; 4];
712 bytes.extend(other.encode_utf8(&mut buf).as_bytes());
713 }
714 None => bytes.push(b'\\'),
715 }
716 } else {
717 let mut buf = [0u8; 4];
718 bytes.extend(c.encode_utf8(&mut buf).as_bytes());
719 }
720 }
721 String::from_utf8_lossy(&bytes).into_owned()
722}
723
724fn parse_hunk_header(line: &str) -> Result<Hunk, String> {
725 let trimmed = line
726 .strip_prefix("@@ ")
727 .ok_or_else(|| format!("invalid hunk header: {line}"))?;
728
729 let end = trimmed
730 .find(" @@")
731 .ok_or_else(|| format!("invalid hunk header (no closing @@): {line}"))?;
732 let range_part = &trimmed[..end];
733
734 let parts: Vec<&str> = range_part.split_whitespace().collect();
735 if parts.len() != 2 {
736 return Err(format!("invalid hunk header ranges: {line}"));
737 }
738
739 let (old_start, old_count) = parse_range(parts[0].strip_prefix('-').unwrap_or(parts[0]))?;
740 let (new_start, new_count) = parse_range(parts[1].strip_prefix('+').unwrap_or(parts[1]))?;
741
742 Ok(Hunk {
743 old_start,
744 old_count,
745 new_start,
746 new_count,
747 lines: Vec::new(),
748 old_no_final_newline: false,
749 new_no_final_newline: false,
750 })
751}
752
753fn parse_range(s: &str) -> Result<(usize, usize), String> {
754 if let Some((a, b)) = s.split_once(',') {
755 let start = a
756 .parse::<usize>()
757 .map_err(|e| format!("bad range start '{a}': {e}"))?;
758 let count = b
759 .parse::<usize>()
760 .map_err(|e| format!("bad range count '{b}': {e}"))?;
761 Ok((start, count))
762 } else {
763 let start = s
764 .parse::<usize>()
765 .map_err(|e| format!("bad range '{s}': {e}"))?;
766 Ok((start, 1))
767 }
768}
769
770const FUZZ_RANGE: usize = 3;
771
772const CONFLICT_OURS: &str = "<<<<<<< patchloom (ours)";
773const CONFLICT_SEP: &str = "=======";
774const CONFLICT_THEIRS: &str = ">>>>>>> patch (theirs)";
775
776#[derive(
777 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
778)]
779#[serde(rename_all = "lowercase")]
780pub enum OnStale {
781 #[default]
782 Fail,
783 Merge,
784}
785
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub struct ApplyHunksOptions {
788 pub on_stale: OnStale,
789 pub allow_conflicts: bool,
790}
791impl Default for ApplyHunksOptions {
792 fn default() -> Self {
793 Self {
794 on_stale: OnStale::Fail,
795 allow_conflicts: false,
796 }
797 }
798}
799
800#[derive(Debug, Clone, PartialEq, Eq)]
801pub struct ConflictRange {
802 pub start_line: usize,
803 pub end_line: usize,
804}
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub struct MergeResult {
807 pub content: String,
808 pub conflicts: Vec<ConflictRange>,
809}
810#[derive(Debug, Clone, PartialEq, Eq)]
811pub struct MergeError {
812 pub message: String,
813}
814impl std::fmt::Display for MergeError {
815 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
816 write!(f, "{}", self.message)
817 }
818}
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub enum ApplyHunksStatus {
821 Clean,
822 Merged,
823 Conflict,
824}
825impl ApplyHunksStatus {
826 pub fn as_str(self) -> &'static str {
827 match self {
828 ApplyHunksStatus::Clean => "clean",
829 ApplyHunksStatus::Merged => "merged",
830 ApplyHunksStatus::Conflict => "conflict",
831 }
832 }
833}
834#[derive(Debug, Clone, PartialEq, Eq)]
835pub struct ApplyHunksResult {
836 pub content: String,
837 pub status: ApplyHunksStatus,
838 pub conflicts: Vec<ConflictRange>,
839}
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct PatchApplyFileResult {
842 pub path: String,
843 pub content: String,
844 pub status: ApplyHunksStatus,
845 pub conflicts: Vec<ConflictRange>,
846 pub is_creation: bool,
848 pub is_deletion: bool,
850 pub rename_from: Option<String>,
852 pub copy_from: Option<String>,
854}
855
856pub fn apply_hunks(original: &str, hunks: &[Hunk]) -> Result<String, String> {
857 let eol = detect_eol(original);
858 let mut src_lines: Vec<String> = original.lines().map(String::from).collect();
859 let mut had_final_newline =
860 original.ends_with('\n') || original.ends_with("\r\n") || original.is_empty();
861 let mut offset: isize = 0;
862
863 for (hunk_idx, hunk) in hunks.iter().enumerate() {
864 let expected = hunk_expected_start(hunk, offset)
865 .map_err(|msg| format!("hunk {} failed: {msg}", hunk_idx + 1))?;
866
867 let old_refs: Vec<&str> = hunk
869 .lines
870 .iter()
871 .filter_map(|pl| match pl {
872 PatchLine::Context(s) | PatchLine::Remove(s) => Some(s.as_str()),
873 _ => None,
874 })
875 .collect();
876
877 let src_refs: Vec<&str> = src_lines.iter().map(std::string::String::as_str).collect();
878
879 let pos = find_match(&src_refs, &old_refs, expected, FUZZ_RANGE).ok_or_else(|| {
880 let snippet = old_refs
881 .iter()
882 .take(3)
883 .copied()
884 .collect::<Vec<_>>()
885 .join("\n");
886 format!(
887 "hunk {} failed: stale context near line {} — expected:\n{}",
888 hunk_idx + 1,
889 hunk.old_start,
890 snippet,
891 )
892 })?;
893
894 let new_lines: Vec<String> = hunk
895 .lines
896 .iter()
897 .filter_map(|pl| match pl {
898 PatchLine::Context(s) => Some(s.clone()),
899 PatchLine::Add(s) => Some(s.clone()),
900 _ => None,
901 })
902 .collect();
903
904 let old_len = old_refs.len();
905 let new_len = new_lines.len();
906 let touches_eof = pos + old_len >= src_lines.len();
909 src_lines.splice(pos..pos + old_len, new_lines);
910 if touches_eof {
911 let last_new = hunk
912 .lines
913 .iter()
914 .rev()
915 .find(|pl| matches!(pl, PatchLine::Add(_) | PatchLine::Context(_)));
916 match last_new {
917 Some(PatchLine::Add(_)) => {
918 if hunk.new_no_final_newline {
919 had_final_newline = false;
921 } else if hunk.old_no_final_newline {
922 had_final_newline = true;
924 }
925 }
928 Some(PatchLine::Context(_)) if hunk.old_no_final_newline => {
929 had_final_newline = false;
931 }
932 Some(PatchLine::Context(_)) if hunk.new_no_final_newline => {
933 had_final_newline = false;
934 }
935 _ => {}
936 }
937 }
938 let delta = isize::try_from(new_len).unwrap_or(isize::MAX)
939 - isize::try_from(old_len).unwrap_or(isize::MAX);
940 offset = offset.saturating_add(delta);
941 }
942
943 Ok(join_lines_with(&src_lines, had_final_newline, eol))
944}
945
946pub fn apply_hunks_with_options(
947 ours: &str,
948 hunks: &[Hunk],
949 options: ApplyHunksOptions,
950) -> Result<ApplyHunksResult, String> {
951 match options.on_stale {
952 OnStale::Fail => {
953 let content = apply_hunks(ours, hunks)?;
954 Ok(ApplyHunksResult {
955 content,
956 status: ApplyHunksStatus::Clean,
957 conflicts: Vec::new(),
958 })
959 }
960 OnStale::Merge => {
961 if let Ok(content) = apply_hunks(ours, hunks) {
962 return Ok(ApplyHunksResult {
963 content,
964 status: ApplyHunksStatus::Clean,
965 conflicts: Vec::new(),
966 });
967 }
968 let merge_result = merge_hunks(ours, hunks).map_err(|e| e.message)?;
969 let status = if !merge_result.conflicts.is_empty() {
973 ApplyHunksStatus::Conflict
974 } else {
975 ApplyHunksStatus::Merged
976 };
977 if status == ApplyHunksStatus::Conflict && !options.allow_conflicts {
978 return Err(format!(
979 "patch merge produced {} conflict(s); pass --allow-conflicts to write conflict markers",
980 merge_result.conflicts.len()
981 ));
982 }
983 Ok(ApplyHunksResult {
984 content: merge_result.content,
985 status,
986 conflicts: merge_result.conflicts,
987 })
988 }
989 }
990}
991
992pub fn merge_hunks(ours: &str, hunks: &[Hunk]) -> Result<MergeResult, MergeError> {
993 let eol = detect_eol(ours);
994 let mut src_lines: Vec<String> = ours.lines().map(String::from).collect();
995 let had_final_newline = ours.ends_with('\n') || ours.ends_with("\r\n") || ours.is_empty();
996 let mut offset: isize = 0;
997 let mut conflicts = Vec::new();
998 for (hunk_idx, hunk) in hunks.iter().enumerate() {
999 let expected = hunk_expected_start(hunk, offset).map_err(|msg| MergeError {
1000 message: format!("hunk {} failed: {msg}", hunk_idx + 1),
1001 })?;
1002 let old_refs = hunk_old_refs(hunk);
1003 let base_lines = hunk_base_lines(hunk);
1004 let theirs_lines = hunk_theirs_lines(hunk);
1005 let src_refs: Vec<&str> = src_lines.iter().map(String::as_str).collect();
1006 let pos = locate_hunk_region(&src_refs, hunk, expected).ok_or_else(|| MergeError {
1007 message: format!(
1008 "hunk {} failed: stale context near line {}",
1009 hunk_idx + 1,
1010 hunk.old_start
1011 ),
1012 })?;
1013 let old_len = old_refs.len();
1014 let ours_region: Vec<String> = src_lines[pos..pos + old_len].to_vec();
1015 let (replacement, hunk_conflicts) =
1016 if ours_region.iter().map(String::as_str).collect::<Vec<_>>() == old_refs {
1017 (theirs_lines, Vec::new())
1018 } else {
1019 merge_three_way(&base_lines, &ours_region, &theirs_lines, pos + 1)
1020 };
1021 conflicts.extend(hunk_conflicts);
1022 let new_len = replacement.len();
1023 src_lines.splice(pos..pos + old_len, replacement);
1024 offset = offset.saturating_add(
1025 isize::try_from(new_len).unwrap_or(isize::MAX)
1026 - isize::try_from(old_len).unwrap_or(isize::MAX),
1027 );
1028 }
1029 Ok(MergeResult {
1030 content: join_lines_with(&src_lines, had_final_newline, eol),
1031 conflicts,
1032 })
1033}
1034
1035fn hunk_expected_start(hunk: &Hunk, offset: isize) -> Result<isize, String> {
1036 if hunk.old_start == 0 {
1037 return Ok(0);
1038 }
1039 let base = if hunk.old_count == 0 {
1042 isize::try_from(hunk.old_start).ok()
1043 } else {
1044 isize::try_from(hunk.old_start)
1045 .ok()
1046 .and_then(|s| s.checked_sub(1))
1047 };
1048 base.and_then(|s| s.checked_add(offset))
1049 .ok_or_else(|| format!("line number {} out of range", hunk.old_start))
1050}
1051fn hunk_old_refs(hunk: &Hunk) -> Vec<&str> {
1052 hunk.lines
1053 .iter()
1054 .filter_map(|pl| match pl {
1055 PatchLine::Context(s) | PatchLine::Remove(s) => Some(s.as_str()),
1056 _ => None,
1057 })
1058 .collect()
1059}
1060fn hunk_base_lines(hunk: &Hunk) -> Vec<String> {
1061 hunk.lines
1062 .iter()
1063 .filter_map(|pl| match pl {
1064 PatchLine::Context(s) | PatchLine::Remove(s) => Some(s.clone()),
1065 _ => None,
1066 })
1067 .collect()
1068}
1069fn hunk_theirs_lines(hunk: &Hunk) -> Vec<String> {
1070 hunk.lines
1071 .iter()
1072 .filter_map(|pl| match pl {
1073 PatchLine::Context(s) | PatchLine::Add(s) => Some(s.clone()),
1074 _ => None,
1075 })
1076 .collect()
1077}
1078fn locate_hunk_region(haystack: &[&str], hunk: &Hunk, expected: isize) -> Option<usize> {
1079 let old_refs = hunk_old_refs(hunk);
1080 find_match(haystack, &old_refs, expected, FUZZ_RANGE)
1081 .or_else(|| find_match_global(haystack, &old_refs))
1082 .or_else(|| locate_by_context_anchors(haystack, hunk, expected))
1083}
1084fn locate_by_context_anchors(haystack: &[&str], hunk: &Hunk, expected: isize) -> Option<usize> {
1085 let old_refs = hunk_old_refs(hunk);
1086 let base_len = old_refs.len();
1087 if base_len == 0 {
1088 return Some((expected.max(0) as usize).min(haystack.len()));
1089 }
1090 let (prefix_ctx, suffix_ctx) = hunk_context_anchors(hunk);
1091 if prefix_ctx.is_empty() && suffix_ctx.is_empty() {
1092 return None;
1093 }
1094 let prefix_refs: Vec<&str> = prefix_ctx.iter().map(String::as_str).collect();
1095 let pos = if prefix_ctx.is_empty() {
1096 None
1097 } else {
1098 find_match(haystack, &prefix_refs, expected, FUZZ_RANGE)
1099 .or_else(|| find_match_global(haystack, &prefix_refs))
1100 };
1101 let pos = if let Some(pos) = pos {
1102 pos
1103 } else if !suffix_ctx.is_empty() {
1104 let suffix_refs: Vec<&str> = suffix_ctx.iter().map(String::as_str).collect();
1105 let suffix_expected = expected
1106 .saturating_add(isize::try_from(base_len).unwrap_or(isize::MAX))
1107 .saturating_sub(isize::try_from(suffix_refs.len()).unwrap_or(isize::MAX));
1108 let suffix_pos = find_match(haystack, &suffix_refs, suffix_expected, FUZZ_RANGE)
1109 .or_else(|| find_match_global(haystack, &suffix_refs))?;
1110 suffix_pos.saturating_sub(base_len.saturating_sub(suffix_refs.len()))
1111 } else {
1112 return None;
1113 };
1114 if !suffix_ctx.is_empty() {
1115 let suffix_start = pos + base_len.saturating_sub(suffix_ctx.len());
1116 if suffix_start + suffix_ctx.len() > haystack.len() {
1117 return None;
1118 }
1119 let suffix_refs: Vec<&str> = suffix_ctx.iter().map(String::as_str).collect();
1120 if haystack[suffix_start..suffix_start + suffix_refs.len()] != *suffix_refs {
1121 return None;
1122 }
1123 }
1124 if pos + base_len > haystack.len() {
1125 return None;
1126 }
1127 Some(pos)
1128}
1129fn hunk_context_anchors(hunk: &Hunk) -> (Vec<String>, Vec<String>) {
1130 let mut prefix_ctx = Vec::new();
1132 for pl in &hunk.lines {
1133 match pl {
1134 PatchLine::Context(s) => prefix_ctx.push(s.clone()),
1135 _ => break,
1136 }
1137 }
1138 let mut suffix_ctx = Vec::new();
1141 for pl in hunk.lines.iter().rev() {
1142 match pl {
1143 PatchLine::Context(s) => suffix_ctx.push(s.clone()),
1144 _ => break,
1145 }
1146 }
1147 suffix_ctx.reverse();
1148 (prefix_ctx, suffix_ctx)
1149}
1150fn merge_three_way(
1151 base: &[String],
1152 ours: &[String],
1153 theirs: &[String],
1154 region_start_line: usize,
1155) -> (Vec<String>, Vec<ConflictRange>) {
1156 if base.len() == ours.len() && base.len() == theirs.len() {
1157 merge_three_way_lines(base, ours, theirs, region_start_line)
1158 } else {
1159 merge_three_way_block(base, ours, theirs, region_start_line)
1160 }
1161}
1162fn merge_three_way_lines(
1163 base: &[String],
1164 ours: &[String],
1165 theirs: &[String],
1166 region_start_line: usize,
1167) -> (Vec<String>, Vec<ConflictRange>) {
1168 let mut out = Vec::new();
1169 let mut conflicts = Vec::new();
1170 let mut line_no = region_start_line;
1171 for i in 0..base.len() {
1172 let (b, o, t) = (&base[i], &ours[i], &theirs[i]);
1173 if o == b && t == b {
1174 out.push(o.clone());
1175 line_no += 1;
1176 } else if o == b {
1177 out.push(t.clone());
1178 line_no += 1;
1179 } else if t == b || o == t {
1180 out.push(o.clone());
1181 line_no += 1;
1182 } else {
1183 let start = line_no;
1184 out.extend([
1185 CONFLICT_OURS.to_string(),
1186 o.clone(),
1187 CONFLICT_SEP.to_string(),
1188 t.clone(),
1189 CONFLICT_THEIRS.to_string(),
1190 ]);
1191 conflicts.push(ConflictRange {
1192 start_line: start,
1193 end_line: start + 4,
1194 });
1195 line_no += 5;
1196 }
1197 }
1198 (out, conflicts)
1199}
1200fn merge_three_way_block(
1201 base: &[String],
1202 ours: &[String],
1203 theirs: &[String],
1204 region_start_line: usize,
1205) -> (Vec<String>, Vec<ConflictRange>) {
1206 if ours == base {
1207 return (theirs.to_vec(), Vec::new());
1208 }
1209 if theirs == base {
1210 return (ours.to_vec(), Vec::new());
1211 }
1212 if ours == theirs {
1213 return (ours.to_vec(), Vec::new());
1214 }
1215 let start = region_start_line;
1216 let mut out = vec![CONFLICT_OURS.to_string()];
1217 out.extend(ours.iter().cloned());
1218 out.push(CONFLICT_SEP.to_string());
1219 out.extend(theirs.iter().cloned());
1220 out.push(CONFLICT_THEIRS.to_string());
1221 let end = start + out.len().saturating_sub(1);
1222 (
1223 out,
1224 vec![ConflictRange {
1225 start_line: start,
1226 end_line: end,
1227 }],
1228 )
1229}
1230fn find_match_global(haystack: &[&str], needle: &[&str]) -> Option<usize> {
1231 if needle.is_empty() {
1232 return Some(0);
1233 }
1234 if needle.len() > haystack.len() {
1235 return None;
1236 }
1237 let max_start = haystack.len() - needle.len();
1238 for pos in 0..=max_start {
1239 if haystack[pos..pos + needle.len()] == *needle {
1240 return Some(pos);
1241 }
1242 }
1243 None
1244}
1245fn join_lines_with(lines: &[String], final_newline: bool, eol: &str) -> String {
1246 if lines.is_empty() {
1247 return String::new();
1248 }
1249 let mut out = lines.join(eol);
1250 if final_newline {
1251 out.push_str(eol);
1252 }
1253 out
1254}
1255
1256fn detect_eol(text: &str) -> &'static str {
1258 crate::write::detect_eol(text)
1259}
1260
1261fn find_match(haystack: &[&str], needle: &[&str], expected: isize, fuzz: usize) -> Option<usize> {
1262 if needle.is_empty() {
1263 let pos = expected.max(0) as usize;
1264 return Some(pos.min(haystack.len()));
1265 }
1266
1267 for delta in 0..=fuzz {
1268 for &sign in &[1isize, -1isize] {
1269 let Some(offset) = isize::try_from(delta).ok() else {
1270 continue;
1271 };
1272 let Some(candidate) = offset
1273 .checked_mul(sign)
1274 .and_then(|o| expected.checked_add(o))
1275 else {
1276 continue;
1277 };
1278 if candidate < 0 {
1279 continue;
1280 }
1281 let pos = candidate as usize;
1282 if pos + needle.len() > haystack.len() {
1283 continue;
1284 }
1285 if haystack[pos..pos + needle.len()] == *needle {
1286 return Some(pos);
1287 }
1288 }
1289 }
1290
1291 None
1292}
1293
1294pub(crate) const STALE_HUNKED_DELETE_RECOVERY: &str = "This was a deletion hunk; the file was not removed. Regenerate minus lines from the current file, or use file.delete for path-only unlink.";
1297
1298pub(crate) fn append_stale_hunked_delete_recovery(msg: &str) -> String {
1299 format!("{msg} {STALE_HUNKED_DELETE_RECOVERY}")
1300}
1301
1302#[cfg(any(feature = "cli", feature = "files"))]
1303pub(crate) fn apply_patch_with_loader<F>(
1304 diff_text: &str,
1305 mut load_original: F,
1306 options: ApplyHunksOptions,
1307) -> anyhow::Result<Vec<PatchApplyFileResult>>
1308where
1309 F: FnMut(&str) -> anyhow::Result<String>,
1310{
1311 let patch_files = parse_patch(diff_text)
1312 .map_err(|msg| crate::ops::search_replace::unified_parse_anyhow(diff_text, &msg))?;
1313 let mut results = Vec::new();
1314 for pf in &patch_files {
1315 if let Some(reason) = pf.unsupported.as_deref() {
1316 return Err(anyhow::Error::new(crate::exit::InvalidInputError {
1317 msg: unsupported_git_meta_msg(&pf.path, reason),
1318 }));
1319 }
1320 let load_path = pf
1324 .copy_from
1325 .as_deref()
1326 .or(pf.rename_from.as_deref())
1327 .unwrap_or(pf.path.as_str());
1328 let pure_rename =
1329 pf.rename_from.is_some() && pf.hunks.is_empty() && !pf.is_deletion && !pf.is_creation;
1330 let pure_copy = pf.copy_from.is_some() && pf.hunks.is_empty();
1331 let original = if pf.copy_from.is_some() {
1332 load_original(load_path)?
1333 } else if pf.is_creation || (pf.is_deletion && pf.hunks.is_empty()) {
1334 String::new()
1337 } else {
1338 match load_original(load_path) {
1339 Ok(s) => s,
1340 Err(e)
1343 if pure_rename
1344 && (crate::exit::is_binary(&e) || crate::exit::is_invalid_encoding(&e)) =>
1345 {
1346 String::new()
1347 }
1348 Err(e) => return Err(e),
1349 }
1350 };
1351 if pure_rename {
1353 results.push(PatchApplyFileResult {
1354 path: pf.path.clone(),
1355 content: original,
1356 status: ApplyHunksStatus::Clean,
1357 conflicts: Vec::new(),
1358 is_creation: false,
1359 is_deletion: false,
1360 rename_from: pf.rename_from.clone(),
1361 copy_from: None,
1362 });
1363 continue;
1364 }
1365 if pure_copy {
1367 results.push(PatchApplyFileResult {
1368 path: pf.path.clone(),
1369 content: original,
1370 status: ApplyHunksStatus::Clean,
1371 conflicts: Vec::new(),
1372 is_creation: true,
1373 is_deletion: false,
1374 rename_from: None,
1375 copy_from: pf.copy_from.clone(),
1376 });
1377 continue;
1378 }
1379 if pf.is_deletion {
1383 if pf.hunks.is_empty() {
1384 results.push(PatchApplyFileResult {
1386 path: pf.path.clone(),
1387 content: String::new(),
1388 status: ApplyHunksStatus::Clean,
1389 conflicts: Vec::new(),
1390 is_creation: false,
1391 is_deletion: true,
1392 rename_from: None,
1393 copy_from: None,
1394 });
1395 continue;
1396 }
1397 let applied = match apply_hunks_with_options(&original, &pf.hunks, options) {
1398 Ok(applied) => applied,
1399 Err(msg) if msg.contains("conflict(s)") => {
1400 return Err(crate::exit::ConflictsError {
1401 msg: format!("patch apply: {} -- {msg}", pf.path),
1402 }
1403 .into());
1404 }
1405 Err(msg) if msg.contains("stale context") => {
1406 return Err(crate::exit::AmbiguousError {
1407 msg: format!(
1408 "patch apply: {} -- {}",
1409 pf.path,
1410 append_stale_hunked_delete_recovery(&msg)
1411 ),
1412 }
1413 .into());
1414 }
1415 Err(msg) => {
1416 return Err(anyhow::Error::new(crate::exit::InvalidInputError {
1417 msg: format!("patch apply: {} -- {msg}", pf.path),
1418 }));
1419 }
1420 };
1421 let is_clean_delete =
1424 applied.status == ApplyHunksStatus::Clean && applied.content.is_empty();
1425 results.push(PatchApplyFileResult {
1426 path: pf.path.clone(),
1427 content: applied.content,
1428 status: applied.status,
1429 conflicts: applied.conflicts,
1430 is_creation: false,
1431 is_deletion: is_clean_delete,
1432 rename_from: None,
1433 copy_from: None,
1434 });
1435 continue;
1436 }
1437 let applied = match apply_hunks_with_options(&original, &pf.hunks, options) {
1438 Ok(applied) => applied,
1439 Err(msg) if msg.contains("conflict(s)") => {
1442 return Err(crate::exit::ConflictsError {
1443 msg: format!("patch apply: {} -- {msg}", pf.path),
1444 }
1445 .into());
1446 }
1447 Err(msg) if msg.contains("stale context") => {
1448 return Err(crate::exit::AmbiguousError {
1449 msg: format!("patch apply: {} -- {msg}", pf.path),
1450 }
1451 .into());
1452 }
1453 Err(msg) => {
1454 return Err(anyhow::Error::new(crate::exit::InvalidInputError {
1455 msg: format!("patch apply: {} -- {msg}", pf.path),
1456 }));
1457 }
1458 };
1459 results.push(PatchApplyFileResult {
1460 path: pf.path.clone(),
1461 content: applied.content,
1462 status: applied.status,
1463 conflicts: applied.conflicts,
1464 is_creation: pf.is_creation,
1465 is_deletion: false,
1466 rename_from: pf.rename_from.clone(),
1467 copy_from: pf.copy_from.clone(),
1468 });
1469 }
1470 Ok(results)
1471}
1472
1473#[path = "patch_tests.rs"]
1474#[cfg(test)]
1475mod tests;