1use once_cell::sync::Lazy;
10use regex::Regex;
11use std::ffi::OsStr;
12use std::io;
13use std::path::Path;
14use std::path::PathBuf;
15
16#[derive(Debug, Clone)]
18pub struct ApplyGitRequest {
19 pub cwd: PathBuf,
20 pub diff: String,
21 pub revert: bool,
22 pub preflight: bool,
23}
24
25#[derive(Debug, Clone)]
27pub struct ApplyGitResult {
28 pub exit_code: i32,
29 pub applied_paths: Vec<String>,
30 pub skipped_paths: Vec<String>,
31 pub conflicted_paths: Vec<String>,
32 pub stdout: String,
33 pub stderr: String,
34 pub cmd_for_log: String,
35}
36
37pub fn apply_git_patch(req: &ApplyGitRequest) -> io::Result<ApplyGitResult> {
42 let git_root = resolve_git_root(&req.cwd)?;
43
44 let (tmpdir, patch_path) = write_temp_patch(&req.diff)?;
46 let _guard = tmpdir;
48
49 if req.revert && !req.preflight {
50 stage_paths(&git_root, &req.diff)?;
52 }
53
54 let mut args: Vec<String> = vec!["apply".into(), "--3way".into()];
56 if req.revert {
57 args.push("-R".into());
58 }
59
60 let mut cfg_parts: Vec<String> = Vec::new();
62 if let Ok(cfg) = std::env::var("CODEX_APPLY_GIT_CFG") {
63 for pair in cfg.split(',') {
64 let p = pair.trim();
65 if p.is_empty() || !p.contains('=') {
66 continue;
67 }
68 cfg_parts.push("-c".into());
69 cfg_parts.push(p.to_string());
70 }
71 }
72
73 args.push(patch_path.to_string_lossy().to_string());
74
75 if req.preflight {
77 let mut check_args = vec!["apply".to_string(), "--check".to_string()];
78 if req.revert {
79 check_args.push("-R".to_string());
80 }
81 check_args.push(patch_path.to_string_lossy().to_string());
82 let rendered = render_command_for_log(&git_root, &cfg_parts, &check_args);
83 let (c_code, c_out, c_err) = run_git(&git_root, &cfg_parts, &check_args)?;
84 let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
85 parse_git_apply_output(&c_out, &c_err);
86 applied_paths.sort();
87 applied_paths.dedup();
88 skipped_paths.sort();
89 skipped_paths.dedup();
90 conflicted_paths.sort();
91 conflicted_paths.dedup();
92 return Ok(ApplyGitResult {
93 exit_code: c_code,
94 applied_paths,
95 skipped_paths,
96 conflicted_paths,
97 stdout: c_out,
98 stderr: c_err,
99 cmd_for_log: rendered,
100 });
101 }
102
103 let cmd_for_log = render_command_for_log(&git_root, &cfg_parts, &args);
104 let (code, stdout, stderr) = run_git(&git_root, &cfg_parts, &args)?;
105
106 let (mut applied_paths, mut skipped_paths, mut conflicted_paths) =
107 parse_git_apply_output(&stdout, &stderr);
108 applied_paths.sort();
109 applied_paths.dedup();
110 skipped_paths.sort();
111 skipped_paths.dedup();
112 conflicted_paths.sort();
113 conflicted_paths.dedup();
114
115 Ok(ApplyGitResult {
116 exit_code: code,
117 applied_paths,
118 skipped_paths,
119 conflicted_paths,
120 stdout,
121 stderr,
122 cmd_for_log,
123 })
124}
125
126fn resolve_git_root(cwd: &Path) -> io::Result<PathBuf> {
127 let out = std::process::Command::new("git")
128 .arg("rev-parse")
129 .arg("--show-toplevel")
130 .current_dir(cwd)
131 .output()?;
132 let code = out.status.code().unwrap_or(-1);
133 if code != 0 {
134 return Err(io::Error::other(format!(
135 "not a git repository (exit {}): {}",
136 code,
137 String::from_utf8_lossy(&out.stderr)
138 )));
139 }
140 let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
141 Ok(PathBuf::from(root))
142}
143
144fn write_temp_patch(diff: &str) -> io::Result<(tempfile::TempDir, PathBuf)> {
145 let dir = tempfile::tempdir()?;
146 let path = dir.path().join("patch.diff");
147 std::fs::write(&path, diff)?;
148 Ok((dir, path))
149}
150
151fn run_git(cwd: &Path, git_cfg: &[String], args: &[String]) -> io::Result<(i32, String, String)> {
152 let mut cmd = std::process::Command::new("git");
153 for p in git_cfg {
154 cmd.arg(p);
155 }
156 for a in args {
157 cmd.arg(a);
158 }
159 let out = cmd.current_dir(cwd).output()?;
160 let code = out.status.code().unwrap_or(-1);
161 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
162 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
163 Ok((code, stdout, stderr))
164}
165
166fn quote_shell(s: &str) -> String {
167 let simple = s
168 .chars()
169 .all(|c| c.is_ascii_alphanumeric() || "-_.:/@%+".contains(c));
170 if simple {
171 s.to_string()
172 } else {
173 format!("'{}'", s.replace('\'', "'\\''"))
174 }
175}
176
177fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> String {
178 let mut parts: Vec<String> = Vec::new();
179 parts.push("git".to_string());
180 for a in git_cfg {
181 parts.push(quote_shell(a));
182 }
183 for a in args {
184 parts.push(quote_shell(a));
185 }
186 format!(
187 "(cd {} && {})",
188 quote_shell(&cwd.display().to_string()),
189 parts.join(" ")
190 )
191}
192
193pub fn extract_paths_from_patch(diff_text: &str) -> Vec<String> {
195 let mut set = std::collections::BTreeSet::new();
196 for raw_line in diff_text.lines() {
197 let line = raw_line.trim();
198 let Some(rest) = line.strip_prefix("diff --git ") else {
199 continue;
200 };
201 let Some((a, b)) = parse_diff_git_paths(rest) else {
202 continue;
203 };
204 if let Some(a) = normalize_diff_path(&a, "a/") {
205 set.insert(a);
206 }
207 if let Some(b) = normalize_diff_path(&b, "b/") {
208 set.insert(b);
209 }
210 }
211 set.into_iter().collect()
212}
213
214fn parse_diff_git_paths(line: &str) -> Option<(String, String)> {
215 let mut chars = line.chars().peekable();
216 let first = read_diff_git_token(&mut chars)?;
217 let second = read_diff_git_token(&mut chars)?;
218 Some((first, second))
219}
220
221fn read_diff_git_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
222 while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
223 chars.next();
224 }
225 let quote = match chars.peek().copied() {
226 Some('"') | Some('\'') => chars.next(),
227 _ => None,
228 };
229 let mut out = String::new();
230 while let Some(c) = chars.next() {
231 if let Some(q) = quote {
232 if c == q {
233 break;
234 }
235 if c == '\\' {
236 out.push('\\');
237 if let Some(next) = chars.next() {
238 out.push(next);
239 }
240 continue;
241 }
242 } else if c.is_whitespace() {
243 break;
244 }
245 out.push(c);
246 }
247 if out.is_empty() && quote.is_none() {
248 None
249 } else {
250 Some(match quote {
251 Some(_) => unescape_c_string(&out),
252 None => out,
253 })
254 }
255}
256
257fn normalize_diff_path(raw: &str, prefix: &str) -> Option<String> {
258 let trimmed = raw.trim();
259 if trimmed.is_empty() {
260 return None;
261 }
262 if trimmed == "/dev/null" || trimmed == format!("{prefix}dev/null") {
263 return None;
264 }
265 let trimmed = trimmed.strip_prefix(prefix).unwrap_or(trimmed);
266 if trimmed.is_empty() {
267 return None;
268 }
269 Some(trimmed.to_string())
270}
271
272fn unescape_c_string(input: &str) -> String {
273 let mut out = String::with_capacity(input.len());
274 let mut chars = input.chars().peekable();
275 while let Some(c) = chars.next() {
276 if c != '\\' {
277 out.push(c);
278 continue;
279 }
280 let Some(next) = chars.next() else {
281 out.push('\\');
282 break;
283 };
284 match next {
285 'n' => out.push('\n'),
286 'r' => out.push('\r'),
287 't' => out.push('\t'),
288 'b' => out.push('\u{0008}'),
289 'f' => out.push('\u{000C}'),
290 'a' => out.push('\u{0007}'),
291 'v' => out.push('\u{000B}'),
292 '\\' => out.push('\\'),
293 '"' => out.push('"'),
294 '\'' => out.push('\''),
295 '0'..='7' => {
296 let mut value = next.to_digit(8).unwrap_or(0);
297 for _ in 0..2 {
298 match chars.peek() {
299 Some('0'..='7') => {
300 if let Some(digit) = chars.next() {
301 value = value * 8 + digit.to_digit(8).unwrap_or(0);
302 } else {
303 break;
304 }
305 }
306 _ => break,
307 }
308 }
309 if let Some(ch) = std::char::from_u32(value) {
310 out.push(ch);
311 }
312 }
313 other => out.push(other),
314 }
315 }
316 out
317}
318
319pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
321 let paths = extract_paths_from_patch(diff);
322 let mut existing: Vec<String> = Vec::new();
323 for p in paths {
324 let joined = git_root.join(&p);
325 if std::fs::symlink_metadata(&joined).is_ok() {
326 existing.push(p);
327 }
328 }
329 if existing.is_empty() {
330 return Ok(());
331 }
332 let mut cmd = std::process::Command::new("git");
333 cmd.arg("add");
334 cmd.arg("--");
335 for p in &existing {
336 cmd.arg(OsStr::new(p));
337 }
338 let out = cmd.current_dir(git_root).output()?;
339 let _code = out.status.code().unwrap_or(-1);
340 Ok(())
342}
343
344pub fn parse_git_apply_output(
348 stdout: &str,
349 stderr: &str,
350) -> (Vec<String>, Vec<String>, Vec<String>) {
351 let combined = [stdout, stderr]
352 .iter()
353 .filter(|s| !s.is_empty())
354 .cloned()
355 .collect::<Vec<&str>>()
356 .join("\n");
357
358 let mut applied = std::collections::BTreeSet::new();
359 let mut skipped = std::collections::BTreeSet::new();
360 let mut conflicted = std::collections::BTreeSet::new();
361 let mut last_seen_path: Option<String> = None;
362
363 fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) {
364 let trimmed = raw.trim();
365 if trimmed.is_empty() {
366 return;
367 }
368 let first = trimmed.chars().next().unwrap_or('\0');
369 let last = trimmed.chars().last().unwrap_or('\0');
370 let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
371 unescape_c_string(&trimmed[1..trimmed.len() - 1])
372 } else {
373 trimmed.to_string()
374 };
375 if !unquoted.is_empty() {
376 set.insert(unquoted);
377 }
378 }
379
380 static APPLIED_CLEAN: Lazy<Regex> =
381 Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
382 static APPLIED_CONFLICTS: Lazy<Regex> =
383 Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
384 static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
385 regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
386 });
387 static CHECKING_PATCH: Lazy<Regex> =
388 Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
389 static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
390 static PATCH_FAILED: Lazy<Regex> =
391 Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
392 static DOES_NOT_APPLY: Lazy<Regex> =
393 Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
394 static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
395 regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
396 });
397 static THREE_WAY_FAILED: Lazy<Regex> =
398 Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
399 static FALLBACK_DIRECT: Lazy<Regex> =
400 Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
401 static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
402 regex_ci(
403 "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
404 )
405 });
406 static INDEX_MISMATCH: Lazy<Regex> =
407 Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
408 static NOT_IN_INDEX: Lazy<Regex> =
409 Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
410 static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
411 regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
412 });
413 static FILE_EXISTS: Lazy<Regex> =
414 Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
415 static RENAMED_DELETED: Lazy<Regex> =
416 Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
417 static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
418 regex_ci(
419 "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
420 )
421 });
422 static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
423 regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
424 });
425 static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
426 regex_ci(
427 "^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
428 )
429 });
430 static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
431 regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
432 });
433 static SKIPPED_PATCH: Lazy<Regex> =
434 Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
435 static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
436 regex_ci(
437 "^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
438 )
439 });
440
441 for raw_line in combined.lines() {
442 let line = raw_line.trim();
443 if line.is_empty() {
444 continue;
445 }
446
447 if let Some(c) = CHECKING_PATCH.captures(line) {
449 if let Some(m) = c.name("path") {
450 last_seen_path = Some(m.as_str().to_string());
451 }
452 continue;
453 }
454
455 if let Some(c) = APPLIED_CLEAN.captures(line) {
457 if let Some(m) = c.name("path") {
458 add(&mut applied, m.as_str());
459 let p = applied.iter().next_back().cloned();
460 if let Some(p) = p {
461 conflicted.remove(&p);
462 skipped.remove(&p);
463 last_seen_path = Some(p);
464 }
465 }
466 continue;
467 }
468 if let Some(c) = APPLIED_CONFLICTS.captures(line) {
469 if let Some(m) = c.name("path") {
470 add(&mut conflicted, m.as_str());
471 let p = conflicted.iter().next_back().cloned();
472 if let Some(p) = p {
473 applied.remove(&p);
474 skipped.remove(&p);
475 last_seen_path = Some(p);
476 }
477 }
478 continue;
479 }
480 if let Some(c) = APPLYING_WITH_REJECTS.captures(line) {
481 if let Some(m) = c.name("path") {
482 add(&mut conflicted, m.as_str());
483 let p = conflicted.iter().next_back().cloned();
484 if let Some(p) = p {
485 applied.remove(&p);
486 skipped.remove(&p);
487 last_seen_path = Some(p);
488 }
489 }
490 continue;
491 }
492
493 if let Some(c) = UNMERGED_LINE.captures(line) {
495 if let Some(m) = c.name("path") {
496 add(&mut conflicted, m.as_str());
497 let p = conflicted.iter().next_back().cloned();
498 if let Some(p) = p {
499 applied.remove(&p);
500 skipped.remove(&p);
501 last_seen_path = Some(p);
502 }
503 }
504 continue;
505 }
506
507 if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) {
509 if let Some(c) = PATCH_FAILED
510 .captures(line)
511 .or_else(|| DOES_NOT_APPLY.captures(line))
512 && let Some(m) = c.name("path")
513 {
514 add(&mut skipped, m.as_str());
515 last_seen_path = Some(m.as_str().to_string());
516 }
517 continue;
518 }
519
520 if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) {
522 continue;
523 }
524
525 if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) {
527 if let Some(p) = last_seen_path.clone() {
528 add(&mut skipped, &p);
529 applied.remove(&p);
530 conflicted.remove(&p);
531 }
532 continue;
533 }
534
535 if let Some(c) = INDEX_MISMATCH
537 .captures(line)
538 .or_else(|| NOT_IN_INDEX.captures(line))
539 .or_else(|| ALREADY_EXISTS_WT.captures(line))
540 .or_else(|| FILE_EXISTS.captures(line))
541 .or_else(|| RENAMED_DELETED.captures(line))
542 .or_else(|| CANNOT_APPLY_BINARY.captures(line))
543 .or_else(|| BINARY_DOES_NOT_APPLY.captures(line))
544 .or_else(|| BINARY_INCORRECT_RESULT.captures(line))
545 .or_else(|| CANNOT_READ_CURRENT.captures(line))
546 .or_else(|| SKIPPED_PATCH.captures(line))
547 {
548 if let Some(m) = c.name("path") {
549 add(&mut skipped, m.as_str());
550 let p_now = skipped.iter().next_back().cloned();
551 if let Some(p) = p_now {
552 applied.remove(&p);
553 conflicted.remove(&p);
554 last_seen_path = Some(p);
555 }
556 }
557 continue;
558 }
559
560 if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) {
562 if let Some(m) = c.name("path") {
563 add(&mut conflicted, m.as_str());
564 let p = conflicted.iter().next_back().cloned();
565 if let Some(p) = p {
566 applied.remove(&p);
567 skipped.remove(&p);
568 last_seen_path = Some(p);
569 }
570 }
571 continue;
572 }
573 }
574
575 for p in conflicted.iter() {
577 applied.remove(p);
578 skipped.remove(p);
579 }
580 for p in applied.iter() {
581 skipped.remove(p);
582 }
583
584 (
585 applied.into_iter().collect(),
586 skipped.into_iter().collect(),
587 conflicted.into_iter().collect(),
588 )
589}
590
591fn regex_ci(pat: &str) -> Regex {
592 Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use std::path::Path;
599 use std::sync::Mutex;
600 use std::sync::OnceLock;
601
602 fn env_lock() -> &'static Mutex<()> {
603 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
604 LOCK.get_or_init(|| Mutex::new(()))
605 }
606
607 fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
608 let out = std::process::Command::new(args[0])
609 .args(&args[1..])
610 .current_dir(cwd)
611 .output()
612 .expect("spawn ok");
613 (
614 out.status.code().unwrap_or(-1),
615 String::from_utf8_lossy(&out.stdout).into_owned(),
616 String::from_utf8_lossy(&out.stderr).into_owned(),
617 )
618 }
619
620 fn init_repo() -> tempfile::TempDir {
621 let dir = tempfile::tempdir().expect("tempdir");
622 let root = dir.path();
623 let _ = run(root, &["git", "init"]);
625 let _ = run(root, &["git", "config", "user.email", "codex@example.com"]);
626 let _ = run(root, &["git", "config", "user.name", "Codex"]);
627 dir
628 }
629
630 fn read_file_normalized(path: &Path) -> String {
631 std::fs::read_to_string(path)
632 .expect("read file")
633 .replace("\r\n", "\n")
634 }
635
636 #[test]
637 fn extract_paths_handles_quoted_headers() {
638 let diff = "diff --git \"a/hello world.txt\" \"b/hello world.txt\"\nnew file mode 100644\n--- /dev/null\n+++ b/hello world.txt\n@@ -0,0 +1 @@\n+hi\n";
639 let paths = extract_paths_from_patch(diff);
640 assert_eq!(paths, vec!["hello world.txt".to_string()]);
641 }
642
643 #[test]
644 fn extract_paths_ignores_dev_null_header() {
645 let diff = "diff --git a/dev/null b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1 @@\n+hi\n";
646 let paths = extract_paths_from_patch(diff);
647 assert_eq!(paths, vec!["ok.txt".to_string()]);
648 }
649
650 #[test]
651 fn extract_paths_unescapes_c_style_in_quoted_headers() {
652 let diff = "diff --git \"a/hello\\tworld.txt\" \"b/hello\\tworld.txt\"\nnew file mode 100644\n--- /dev/null\n+++ b/hello\tworld.txt\n@@ -0,0 +1 @@\n+hi\n";
653 let paths = extract_paths_from_patch(diff);
654 assert_eq!(paths, vec!["hello\tworld.txt".to_string()]);
655 }
656
657 #[test]
658 fn parse_output_unescapes_quoted_paths() {
659 let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n";
660 let (applied, skipped, conflicted) = parse_git_apply_output("", stderr);
661 assert_eq!(applied, Vec::<String>::new());
662 assert_eq!(conflicted, Vec::<String>::new());
663 assert_eq!(skipped, vec!["hello\tworld.txt".to_string()]);
664 }
665
666 #[test]
667 fn apply_add_success() {
668 let _g = env_lock().lock().unwrap();
669 let repo = init_repo();
670 let root = repo.path();
671
672 let diff = "diff --git a/hello.txt b/hello.txt\nnew file mode 100644\n--- /dev/null\n+++ b/hello.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n";
673 let req = ApplyGitRequest {
674 cwd: root.to_path_buf(),
675 diff: diff.to_string(),
676 revert: false,
677 preflight: false,
678 };
679 let r = apply_git_patch(&req).expect("run apply");
680 assert_eq!(r.exit_code, 0, "exit code 0");
681 assert!(root.join("hello.txt").exists());
683 }
684
685 #[test]
686 fn apply_modify_conflict() {
687 let _g = env_lock().lock().unwrap();
688 let repo = init_repo();
689 let root = repo.path();
690 std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
692 let _ = run(root, &["git", "add", "file.txt"]);
693 let _ = run(root, &["git", "commit", "-m", "seed"]);
694 std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
696 let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+remote2\n line3\n";
698 let req = ApplyGitRequest {
699 cwd: root.to_path_buf(),
700 diff: diff.to_string(),
701 revert: false,
702 preflight: false,
703 };
704 let r = apply_git_patch(&req).expect("run apply");
705 assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
706 }
707
708 #[test]
709 fn apply_modify_skipped_missing_index() {
710 let _g = env_lock().lock().unwrap();
711 let repo = init_repo();
712 let root = repo.path();
713 let diff = "diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
715 let req = ApplyGitRequest {
716 cwd: root.to_path_buf(),
717 diff: diff.to_string(),
718 revert: false,
719 preflight: false,
720 };
721 let r = apply_git_patch(&req).expect("run apply");
722 assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
723 }
724
725 #[test]
726 fn apply_then_revert_success() {
727 let _g = env_lock().lock().unwrap();
728 let repo = init_repo();
729 let root = repo.path();
730 std::fs::write(root.join("file.txt"), "orig\n").unwrap();
732 let _ = run(root, &["git", "add", "file.txt"]);
733 let _ = run(root, &["git", "commit", "-m", "seed"]);
734
735 let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
737 let apply_req = ApplyGitRequest {
738 cwd: root.to_path_buf(),
739 diff: diff.to_string(),
740 revert: false,
741 preflight: false,
742 };
743 let res_apply = apply_git_patch(&apply_req).expect("apply ok");
744 assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
745 let after_apply = read_file_normalized(&root.join("file.txt"));
746 assert_eq!(after_apply, "ORIG\n");
747
748 let revert_req = ApplyGitRequest {
750 cwd: root.to_path_buf(),
751 diff: diff.to_string(),
752 revert: true,
753 preflight: false,
754 };
755 let res_revert = apply_git_patch(&revert_req).expect("revert ok");
756 assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
757 let after_revert = read_file_normalized(&root.join("file.txt"));
758 assert_eq!(after_revert, "orig\n");
759 }
760
761 #[test]
762 fn revert_preflight_does_not_stage_index() {
763 let _g = env_lock().lock().unwrap();
764 let repo = init_repo();
765 let root = repo.path();
766 std::fs::write(root.join("file.txt"), "orig\n").unwrap();
768 let _ = run(root, &["git", "add", "file.txt"]);
769 let _ = run(root, &["git", "commit", "-m", "seed"]);
770
771 let diff = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-orig\n+ORIG\n";
772 let apply_req = ApplyGitRequest {
773 cwd: root.to_path_buf(),
774 diff: diff.to_string(),
775 revert: false,
776 preflight: false,
777 };
778 let res_apply = apply_git_patch(&apply_req).expect("apply ok");
779 assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
780 let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]);
781 assert_eq!(commit_code, 0, "commit applied change: {commit_err}");
782
783 let (_code_before, staged_before, _stderr_before) =
784 run(root, &["git", "diff", "--cached", "--name-only"]);
785
786 let preflight_req = ApplyGitRequest {
787 cwd: root.to_path_buf(),
788 diff: diff.to_string(),
789 revert: true,
790 preflight: true,
791 };
792 let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
793 assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
794 let (_code_after, staged_after, _stderr_after) =
795 run(root, &["git", "diff", "--cached", "--name-only"]);
796 assert_eq!(
797 staged_after.trim(),
798 staged_before.trim(),
799 "preflight should not stage new paths",
800 );
801
802 let after_preflight = read_file_normalized(&root.join("file.txt"));
803 assert_eq!(after_preflight, "ORIG\n");
804 }
805
806 #[test]
807 fn preflight_blocks_partial_changes() {
808 let _g = env_lock().lock().unwrap();
809 let repo = init_repo();
810 let root = repo.path();
811 let diff = "diff --git a/ok.txt b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1,2 @@\n+alpha\n+beta\n\n\
813diff --git a/ghost.txt b/ghost.txt\n--- a/ghost.txt\n+++ b/ghost.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
814
815 let req1 = ApplyGitRequest {
817 cwd: root.to_path_buf(),
818 diff: diff.to_string(),
819 revert: false,
820 preflight: true,
821 };
822 let r1 = apply_git_patch(&req1).expect("preflight apply");
823 assert_ne!(r1.exit_code, 0, "preflight reports failure");
824 assert!(
825 !root.join("ok.txt").exists(),
826 "preflight must prevent adding ok.txt"
827 );
828 assert!(
829 r1.cmd_for_log.contains("--check"),
830 "preflight path recorded --check"
831 );
832
833 let req2 = ApplyGitRequest {
835 cwd: root.to_path_buf(),
836 diff: diff.to_string(),
837 revert: false,
838 preflight: false,
839 };
840 let r2 = apply_git_patch(&req2).expect("direct apply");
841 assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
842 assert!(
843 !r2.cmd_for_log.contains("--check"),
844 "non-preflight path should not use --check"
845 );
846 }
847}