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 static RE: Lazy<Regex> = Lazy::new(|| {
196 Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$")
197 .unwrap_or_else(|e| panic!("invalid regex: {e}"))
198 });
199 let mut set = std::collections::BTreeSet::new();
200 for caps in RE.captures_iter(diff_text) {
201 if let Some(a) = caps.get(1).map(|m| m.as_str())
202 && a != "/dev/null"
203 && !a.trim().is_empty()
204 {
205 set.insert(a.to_string());
206 }
207 if let Some(b) = caps.get(2).map(|m| m.as_str())
208 && b != "/dev/null"
209 && !b.trim().is_empty()
210 {
211 set.insert(b.to_string());
212 }
213 }
214 set.into_iter().collect()
215}
216
217pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
219 let paths = extract_paths_from_patch(diff);
220 let mut existing: Vec<String> = Vec::new();
221 for p in paths {
222 let joined = git_root.join(&p);
223 if std::fs::symlink_metadata(&joined).is_ok() {
224 existing.push(p);
225 }
226 }
227 if existing.is_empty() {
228 return Ok(());
229 }
230 let mut cmd = std::process::Command::new("git");
231 cmd.arg("add");
232 cmd.arg("--");
233 for p in &existing {
234 cmd.arg(OsStr::new(p));
235 }
236 let out = cmd.current_dir(git_root).output()?;
237 let _code = out.status.code().unwrap_or(-1);
238 Ok(())
240}
241
242pub fn parse_git_apply_output(
246 stdout: &str,
247 stderr: &str,
248) -> (Vec<String>, Vec<String>, Vec<String>) {
249 let combined = [stdout, stderr]
250 .iter()
251 .filter(|s| !s.is_empty())
252 .cloned()
253 .collect::<Vec<&str>>()
254 .join("\n");
255
256 let mut applied = std::collections::BTreeSet::new();
257 let mut skipped = std::collections::BTreeSet::new();
258 let mut conflicted = std::collections::BTreeSet::new();
259 let mut last_seen_path: Option<String> = None;
260
261 fn add(set: &mut std::collections::BTreeSet<String>, raw: &str) {
262 let trimmed = raw.trim();
263 if trimmed.is_empty() {
264 return;
265 }
266 let first = trimmed.chars().next().unwrap_or('\0');
267 let last = trimmed.chars().last().unwrap_or('\0');
268 let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
269 &trimmed[1..trimmed.len() - 1]
270 } else {
271 trimmed
272 };
273 if !unquoted.is_empty() {
274 set.insert(unquoted.to_string());
275 }
276 }
277
278 static APPLIED_CLEAN: Lazy<Regex> =
279 Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+cleanly\\.?$"));
280 static APPLIED_CONFLICTS: Lazy<Regex> =
281 Lazy::new(|| regex_ci("^Applied patch(?: to)?\\s+(?P<path>.+?)\\s+with conflicts\\.?$"));
282 static APPLYING_WITH_REJECTS: Lazy<Regex> = Lazy::new(|| {
283 regex_ci("^Applying patch\\s+(?P<path>.+?)\\s+with\\s+\\d+\\s+rejects?\\.{0,3}$")
284 });
285 static CHECKING_PATCH: Lazy<Regex> =
286 Lazy::new(|| regex_ci("^Checking patch\\s+(?P<path>.+?)\\.\\.\\.$"));
287 static UNMERGED_LINE: Lazy<Regex> = Lazy::new(|| regex_ci("^U\\s+(?P<path>.+)$"));
288 static PATCH_FAILED: Lazy<Regex> =
289 Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)(?::\\d+)?(?:\\s|$)"));
290 static DOES_NOT_APPLY: Lazy<Regex> =
291 Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+patch does not apply$"));
292 static THREE_WAY_START: Lazy<Regex> = Lazy::new(|| {
293 regex_ci("^(?:Performing three-way merge|Falling back to three-way merge)\\.\\.\\.$")
294 });
295 static THREE_WAY_FAILED: Lazy<Regex> =
296 Lazy::new(|| regex_ci("^Failed to perform three-way merge\\.\\.\\.$"));
297 static FALLBACK_DIRECT: Lazy<Regex> =
298 Lazy::new(|| regex_ci("^Falling back to direct application\\.\\.\\.$"));
299 static LACKS_BLOB: Lazy<Regex> = Lazy::new(|| {
300 regex_ci(
301 "^(?:error: )?repository lacks the necessary blob to (?:perform|fall back on) 3-?way merge\\.?$",
302 )
303 });
304 static INDEX_MISMATCH: Lazy<Regex> =
305 Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not match index\\b"));
306 static NOT_IN_INDEX: Lazy<Regex> =
307 Lazy::new(|| regex_ci("^error:\\s+(?P<path>.+?):\\s+does not exist in index\\b"));
308 static ALREADY_EXISTS_WT: Lazy<Regex> = Lazy::new(|| {
309 regex_ci("^error:\\s+(?P<path>.+?)\\s+already exists in (?:the )?working directory\\b")
310 });
311 static FILE_EXISTS: Lazy<Regex> =
312 Lazy::new(|| regex_ci("^error:\\s+patch failed:\\s+(?P<path>.+?)\\s+File exists"));
313 static RENAMED_DELETED: Lazy<Regex> =
314 Lazy::new(|| regex_ci("^error:\\s+path\\s+(?P<path>.+?)\\s+has been renamed\\/deleted"));
315 static CANNOT_APPLY_BINARY: Lazy<Regex> = Lazy::new(|| {
316 regex_ci(
317 "^error:\\s+cannot apply binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+without full index line$",
318 )
319 });
320 static BINARY_DOES_NOT_APPLY: Lazy<Regex> = Lazy::new(|| {
321 regex_ci("^error:\\s+binary patch does not apply to\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
322 });
323 static BINARY_INCORRECT_RESULT: Lazy<Regex> = Lazy::new(|| {
324 regex_ci(
325 "^error:\\s+binary patch to\\s+['\\\"]?(?P<path>.+?)['\\\"]?\\s+creates incorrect result\\b",
326 )
327 });
328 static CANNOT_READ_CURRENT: Lazy<Regex> = Lazy::new(|| {
329 regex_ci("^error:\\s+cannot read the current contents of\\s+['\\\"]?(?P<path>.+?)['\\\"]?$")
330 });
331 static SKIPPED_PATCH: Lazy<Regex> =
332 Lazy::new(|| regex_ci("^Skipped patch\\s+['\\\"]?(?P<path>.+?)['\\\"]\\.$"));
333 static CANNOT_MERGE_BINARY_WARN: Lazy<Regex> = Lazy::new(|| {
334 regex_ci(
335 "^warning:\\s*Cannot merge binary files:\\s+(?P<path>.+?)\\s+\\(ours\\s+vs\\.\\s+theirs\\)",
336 )
337 });
338
339 for raw_line in combined.lines() {
340 let line = raw_line.trim();
341 if line.is_empty() {
342 continue;
343 }
344
345 if let Some(c) = CHECKING_PATCH.captures(line) {
347 if let Some(m) = c.name("path") {
348 last_seen_path = Some(m.as_str().to_string());
349 }
350 continue;
351 }
352
353 if let Some(c) = APPLIED_CLEAN.captures(line) {
355 if let Some(m) = c.name("path") {
356 add(&mut applied, m.as_str());
357 let p = applied.iter().next_back().cloned();
358 if let Some(p) = p {
359 conflicted.remove(&p);
360 skipped.remove(&p);
361 last_seen_path = Some(p);
362 }
363 }
364 continue;
365 }
366 if let Some(c) = APPLIED_CONFLICTS.captures(line) {
367 if let Some(m) = c.name("path") {
368 add(&mut conflicted, m.as_str());
369 let p = conflicted.iter().next_back().cloned();
370 if let Some(p) = p {
371 applied.remove(&p);
372 skipped.remove(&p);
373 last_seen_path = Some(p);
374 }
375 }
376 continue;
377 }
378 if let Some(c) = APPLYING_WITH_REJECTS.captures(line) {
379 if let Some(m) = c.name("path") {
380 add(&mut conflicted, m.as_str());
381 let p = conflicted.iter().next_back().cloned();
382 if let Some(p) = p {
383 applied.remove(&p);
384 skipped.remove(&p);
385 last_seen_path = Some(p);
386 }
387 }
388 continue;
389 }
390
391 if let Some(c) = UNMERGED_LINE.captures(line) {
393 if let Some(m) = c.name("path") {
394 add(&mut conflicted, m.as_str());
395 let p = conflicted.iter().next_back().cloned();
396 if let Some(p) = p {
397 applied.remove(&p);
398 skipped.remove(&p);
399 last_seen_path = Some(p);
400 }
401 }
402 continue;
403 }
404
405 if PATCH_FAILED.is_match(line) || DOES_NOT_APPLY.is_match(line) {
407 if let Some(c) = PATCH_FAILED
408 .captures(line)
409 .or_else(|| DOES_NOT_APPLY.captures(line))
410 && let Some(m) = c.name("path")
411 {
412 add(&mut skipped, m.as_str());
413 last_seen_path = Some(m.as_str().to_string());
414 }
415 continue;
416 }
417
418 if THREE_WAY_START.is_match(line) || FALLBACK_DIRECT.is_match(line) {
420 continue;
421 }
422
423 if THREE_WAY_FAILED.is_match(line) || LACKS_BLOB.is_match(line) {
425 if let Some(p) = last_seen_path.clone() {
426 add(&mut skipped, &p);
427 applied.remove(&p);
428 conflicted.remove(&p);
429 }
430 continue;
431 }
432
433 if let Some(c) = INDEX_MISMATCH
435 .captures(line)
436 .or_else(|| NOT_IN_INDEX.captures(line))
437 .or_else(|| ALREADY_EXISTS_WT.captures(line))
438 .or_else(|| FILE_EXISTS.captures(line))
439 .or_else(|| RENAMED_DELETED.captures(line))
440 .or_else(|| CANNOT_APPLY_BINARY.captures(line))
441 .or_else(|| BINARY_DOES_NOT_APPLY.captures(line))
442 .or_else(|| BINARY_INCORRECT_RESULT.captures(line))
443 .or_else(|| CANNOT_READ_CURRENT.captures(line))
444 .or_else(|| SKIPPED_PATCH.captures(line))
445 {
446 if let Some(m) = c.name("path") {
447 add(&mut skipped, m.as_str());
448 let p_now = skipped.iter().next_back().cloned();
449 if let Some(p) = p_now {
450 applied.remove(&p);
451 conflicted.remove(&p);
452 last_seen_path = Some(p);
453 }
454 }
455 continue;
456 }
457
458 if let Some(c) = CANNOT_MERGE_BINARY_WARN.captures(line) {
460 if let Some(m) = c.name("path") {
461 add(&mut conflicted, m.as_str());
462 let p = conflicted.iter().next_back().cloned();
463 if let Some(p) = p {
464 applied.remove(&p);
465 skipped.remove(&p);
466 last_seen_path = Some(p);
467 }
468 }
469 continue;
470 }
471 }
472
473 for p in conflicted.iter() {
475 applied.remove(p);
476 skipped.remove(p);
477 }
478 for p in applied.iter() {
479 skipped.remove(p);
480 }
481
482 (
483 applied.into_iter().collect(),
484 skipped.into_iter().collect(),
485 conflicted.into_iter().collect(),
486 )
487}
488
489fn regex_ci(pat: &str) -> Regex {
490 Regex::new(&format!("(?i){pat}")).unwrap_or_else(|e| panic!("invalid regex: {e}"))
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use std::path::Path;
497 use std::sync::Mutex;
498 use std::sync::OnceLock;
499
500 fn env_lock() -> &'static Mutex<()> {
501 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
502 LOCK.get_or_init(|| Mutex::new(()))
503 }
504
505 fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
506 let out = std::process::Command::new(args[0])
507 .args(&args[1..])
508 .current_dir(cwd)
509 .output()
510 .expect("spawn ok");
511 (
512 out.status.code().unwrap_or(-1),
513 String::from_utf8_lossy(&out.stdout).into_owned(),
514 String::from_utf8_lossy(&out.stderr).into_owned(),
515 )
516 }
517
518 fn init_repo() -> tempfile::TempDir {
519 let dir = tempfile::tempdir().expect("tempdir");
520 let root = dir.path();
521 let _ = run(root, &["git", "init"]);
523 let _ = run(root, &["git", "config", "user.email", "codex@example.com"]);
524 let _ = run(root, &["git", "config", "user.name", "Codex"]);
525 dir
526 }
527
528 fn read_file_normalized(path: &Path) -> String {
529 std::fs::read_to_string(path)
530 .expect("read file")
531 .replace("\r\n", "\n")
532 }
533
534 #[test]
535 fn apply_add_success() {
536 let _g = env_lock().lock().unwrap();
537 let repo = init_repo();
538 let root = repo.path();
539
540 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";
541 let req = ApplyGitRequest {
542 cwd: root.to_path_buf(),
543 diff: diff.to_string(),
544 revert: false,
545 preflight: false,
546 };
547 let r = apply_git_patch(&req).expect("run apply");
548 assert_eq!(r.exit_code, 0, "exit code 0");
549 assert!(root.join("hello.txt").exists());
551 }
552
553 #[test]
554 fn apply_modify_conflict() {
555 let _g = env_lock().lock().unwrap();
556 let repo = init_repo();
557 let root = repo.path();
558 std::fs::write(root.join("file.txt"), "line1\nline2\nline3\n").unwrap();
560 let _ = run(root, &["git", "add", "file.txt"]);
561 let _ = run(root, &["git", "commit", "-m", "seed"]);
562 std::fs::write(root.join("file.txt"), "line1\nlocal2\nline3\n").unwrap();
564 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";
566 let req = ApplyGitRequest {
567 cwd: root.to_path_buf(),
568 diff: diff.to_string(),
569 revert: false,
570 preflight: false,
571 };
572 let r = apply_git_patch(&req).expect("run apply");
573 assert_ne!(r.exit_code, 0, "non-zero exit on conflict");
574 }
575
576 #[test]
577 fn apply_modify_skipped_missing_index() {
578 let _g = env_lock().lock().unwrap();
579 let repo = init_repo();
580 let root = repo.path();
581 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";
583 let req = ApplyGitRequest {
584 cwd: root.to_path_buf(),
585 diff: diff.to_string(),
586 revert: false,
587 preflight: false,
588 };
589 let r = apply_git_patch(&req).expect("run apply");
590 assert_ne!(r.exit_code, 0, "non-zero exit on missing index");
591 }
592
593 #[test]
594 fn apply_then_revert_success() {
595 let _g = env_lock().lock().unwrap();
596 let repo = init_repo();
597 let root = repo.path();
598 std::fs::write(root.join("file.txt"), "orig\n").unwrap();
600 let _ = run(root, &["git", "add", "file.txt"]);
601 let _ = run(root, &["git", "commit", "-m", "seed"]);
602
603 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";
605 let apply_req = ApplyGitRequest {
606 cwd: root.to_path_buf(),
607 diff: diff.to_string(),
608 revert: false,
609 preflight: false,
610 };
611 let res_apply = apply_git_patch(&apply_req).expect("apply ok");
612 assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
613 let after_apply = read_file_normalized(&root.join("file.txt"));
614 assert_eq!(after_apply, "ORIG\n");
615
616 let revert_req = ApplyGitRequest {
618 cwd: root.to_path_buf(),
619 diff: diff.to_string(),
620 revert: true,
621 preflight: false,
622 };
623 let res_revert = apply_git_patch(&revert_req).expect("revert ok");
624 assert_eq!(res_revert.exit_code, 0, "revert apply succeeded");
625 let after_revert = read_file_normalized(&root.join("file.txt"));
626 assert_eq!(after_revert, "orig\n");
627 }
628
629 #[test]
630 fn revert_preflight_does_not_stage_index() {
631 let _g = env_lock().lock().unwrap();
632 let repo = init_repo();
633 let root = repo.path();
634 std::fs::write(root.join("file.txt"), "orig\n").unwrap();
636 let _ = run(root, &["git", "add", "file.txt"]);
637 let _ = run(root, &["git", "commit", "-m", "seed"]);
638
639 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";
640 let apply_req = ApplyGitRequest {
641 cwd: root.to_path_buf(),
642 diff: diff.to_string(),
643 revert: false,
644 preflight: false,
645 };
646 let res_apply = apply_git_patch(&apply_req).expect("apply ok");
647 assert_eq!(res_apply.exit_code, 0, "forward apply succeeded");
648 let (commit_code, _, commit_err) = run(root, &["git", "commit", "-am", "apply change"]);
649 assert_eq!(commit_code, 0, "commit applied change: {commit_err}");
650
651 let (_code_before, staged_before, _stderr_before) =
652 run(root, &["git", "diff", "--cached", "--name-only"]);
653
654 let preflight_req = ApplyGitRequest {
655 cwd: root.to_path_buf(),
656 diff: diff.to_string(),
657 revert: true,
658 preflight: true,
659 };
660 let res_preflight = apply_git_patch(&preflight_req).expect("preflight ok");
661 assert_eq!(res_preflight.exit_code, 0, "revert preflight succeeded");
662 let (_code_after, staged_after, _stderr_after) =
663 run(root, &["git", "diff", "--cached", "--name-only"]);
664 assert_eq!(
665 staged_after.trim(),
666 staged_before.trim(),
667 "preflight should not stage new paths",
668 );
669
670 let after_preflight = read_file_normalized(&root.join("file.txt"));
671 assert_eq!(after_preflight, "ORIG\n");
672 }
673
674 #[test]
675 fn preflight_blocks_partial_changes() {
676 let _g = env_lock().lock().unwrap();
677 let repo = init_repo();
678 let root = repo.path();
679 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\
681diff --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";
682
683 let req1 = ApplyGitRequest {
685 cwd: root.to_path_buf(),
686 diff: diff.to_string(),
687 revert: false,
688 preflight: true,
689 };
690 let r1 = apply_git_patch(&req1).expect("preflight apply");
691 assert_ne!(r1.exit_code, 0, "preflight reports failure");
692 assert!(
693 !root.join("ok.txt").exists(),
694 "preflight must prevent adding ok.txt"
695 );
696 assert!(
697 r1.cmd_for_log.contains("--check"),
698 "preflight path recorded --check"
699 );
700
701 let req2 = ApplyGitRequest {
703 cwd: root.to_path_buf(),
704 diff: diff.to_string(),
705 revert: false,
706 preflight: false,
707 };
708 let r2 = apply_git_patch(&req2).expect("direct apply");
709 assert_ne!(r2.exit_code, 0, "apply is expected to fail overall");
710 assert!(
711 !r2.cmd_for_log.contains("--check"),
712 "non-preflight path should not use --check"
713 );
714 }
715}