Skip to main content

llm_git/
patch.rs

1use std::collections::{BTreeMap, HashSet};
2
3use crate::{
4   compose_types::{ComposeExecutableGroup, ComposeFile, ComposeHunk, ComposeSnapshot},
5   error::{CommitGenError, Result},
6   git::git_command,
7};
8
9#[derive(Debug, Clone)]
10struct ParsedHunk {
11   old_start: usize,
12   old_count: usize,
13   new_start: usize,
14   new_count: usize,
15   header:    String,
16   lines:     Vec<String>,
17}
18
19#[derive(Debug, Clone)]
20struct ParsedFile {
21   path:         String,
22   header_lines: Vec<String>,
23   hunks:        Vec<ParsedHunk>,
24   additions:    usize,
25   deletions:    usize,
26   is_binary:    bool,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ComposeGroupPatch {
31   pub diff:       String,
32   pub stat:       String,
33   apply_patch:    String,
34   fallback_files: Vec<String>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum StageResult {
39   Staged,
40   AlreadyApplied,
41   EmptyPatch,
42}
43
44impl StageResult {
45   const fn combine(self, other: Self) -> Self {
46      match (self, other) {
47         (Self::Staged, _) | (_, Self::Staged) => Self::Staged,
48         (Self::AlreadyApplied, _) | (_, Self::AlreadyApplied) => Self::AlreadyApplied,
49         (Self::EmptyPatch, Self::EmptyPatch) => Self::EmptyPatch,
50      }
51   }
52}
53
54/// Run `git apply` with a patch supplied on stdin.
55fn run_git_apply(patch: &str, args: &[&str], dir: &str) -> Result<std::process::Output> {
56   let mut child = git_command()
57      .args(args)
58      .current_dir(dir)
59      .stdin(std::process::Stdio::piped())
60      .stdout(std::process::Stdio::piped())
61      .stderr(std::process::Stdio::piped())
62      .spawn()
63      .map_err(|e| CommitGenError::git(format!("Failed to spawn git apply: {e}")))?;
64
65   if let Some(mut stdin) = child.stdin.take() {
66      use std::io::Write;
67
68      stdin
69         .write_all(patch.as_bytes())
70         .map_err(|e| CommitGenError::git(format!("Failed to write patch: {e}")))?;
71   }
72
73   child
74      .wait_with_output()
75      .map_err(|e| CommitGenError::git(format!("Failed to wait for git apply: {e}")))
76}
77
78fn patch_is_already_applied_to_index(patch: &str, dir: &str) -> Result<bool> {
79   let output =
80      run_git_apply(patch, &["apply", "--cached", "--reverse", "--check", "--recount"], dir)?;
81   Ok(output.status.success())
82}
83
84/// Apply patch to staging area.
85pub fn apply_patch_to_index(patch: &str, dir: &str) -> Result<StageResult> {
86   if patch.trim().is_empty() {
87      return Ok(StageResult::EmptyPatch);
88   }
89
90   if patch_is_already_applied_to_index(patch, dir)? {
91      return Ok(StageResult::AlreadyApplied);
92   }
93
94   let output = run_git_apply(patch, &["apply", "--cached", "--3way", "--recount"], dir)?;
95   if output.status.success() {
96      return Ok(StageResult::Staged);
97   }
98
99   let stderr = String::from_utf8_lossy(&output.stderr);
100   Err(CommitGenError::git(format!("git apply --cached --3way --recount failed: {stderr}")))
101}
102
103/// Stage specific files.
104pub fn stage_files(files: &[String], dir: &str) -> Result<()> {
105   if files.is_empty() {
106      return Ok(());
107   }
108
109   let output = git_command()
110      .arg("add")
111      .arg("--")
112      .args(files)
113      .current_dir(dir)
114      .output()
115      .map_err(|e| CommitGenError::git(format!("Failed to stage files: {e}")))?;
116
117   if !output.status.success() {
118      let stderr = String::from_utf8_lossy(&output.stderr);
119      return Err(CommitGenError::git(format!("git add failed: {stderr}")));
120   }
121
122   Ok(())
123}
124
125/// Reset staging area.
126pub fn reset_staging(dir: &str) -> Result<()> {
127   let output = git_command()
128      .args(["reset", "HEAD"])
129      .current_dir(dir)
130      .output()
131      .map_err(|e| CommitGenError::git(format!("Failed to reset staging: {e}")))?;
132
133   if !output.status.success() {
134      let stderr = String::from_utf8_lossy(&output.stderr);
135      return Err(CommitGenError::git(format!("git reset HEAD failed: {stderr}")));
136   }
137
138   Ok(())
139}
140
141fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
142   let trimmed = header.trim();
143   if !trimmed.starts_with("@@") {
144      return None;
145   }
146
147   let after_first = trimmed.strip_prefix("@@")?;
148   let middle = after_first.split("@@").next()?.trim();
149   let parts: Vec<&str> = middle.split_whitespace().collect();
150   if parts.len() < 2 {
151      return None;
152   }
153
154   let old_part = parts[0].strip_prefix('-')?;
155   let new_part = parts[1].strip_prefix('+')?;
156
157   let parse_range = |s: &str| -> Option<(usize, usize)> {
158      if let Some((start, count)) = s.split_once(',') {
159         Some((start.parse().ok()?, count.parse().ok()?))
160      } else {
161         Some((s.parse().ok()?, 1))
162      }
163   };
164
165   let (old_start, old_count) = parse_range(old_part)?;
166   let (new_start, new_count) = parse_range(new_part)?;
167   Some((old_start, old_count, new_start, new_count))
168}
169
170fn parse_file_path(diff_header: &str) -> Result<String> {
171   diff_header
172      .split_whitespace()
173      .nth(3)
174      .and_then(|part| part.strip_prefix("b/"))
175      .map(str::to_string)
176      .ok_or_else(|| {
177         CommitGenError::Other(format!("Failed to parse file path from '{diff_header}'"))
178      })
179}
180
181fn finalize_current_hunk(file: &mut ParsedFile, current_hunk: &mut Option<ParsedHunk>) {
182   if let Some(hunk) = current_hunk.take() {
183      file.hunks.push(hunk);
184   }
185}
186
187fn finalize_current_file(
188   files: &mut Vec<ParsedFile>,
189   current_file: &mut Option<ParsedFile>,
190   current_hunk: &mut Option<ParsedHunk>,
191) {
192   if let Some(mut file) = current_file.take() {
193      finalize_current_hunk(&mut file, current_hunk);
194      files.push(file);
195   }
196}
197
198fn join_lines(lines: &[String]) -> String {
199   if lines.is_empty() {
200      String::new()
201   } else {
202      let mut joined = lines.join("\n");
203      joined.push('\n');
204      joined
205   }
206}
207
208fn truncate_snippet(snippet: &str, max_chars: usize) -> String {
209   let trimmed = snippet.trim();
210   if trimmed.chars().count() <= max_chars {
211      return trimmed.to_string();
212   }
213
214   let mut truncated = trimmed.chars().take(max_chars).collect::<String>();
215   truncated.push_str("...");
216   truncated
217}
218
219fn build_hunk_snippet(lines: &[String], fallback: &str) -> String {
220   let interesting: Vec<String> = lines
221      .iter()
222      .skip(1)
223      .filter(|line| {
224         (line.starts_with('+') && !line.starts_with("+++"))
225            || (line.starts_with('-') && !line.starts_with("---"))
226      })
227      .take(3)
228      .map(|line| truncate_snippet(line.trim_start_matches(['+', '-']), 80))
229      .collect();
230
231   if interesting.is_empty() {
232      truncate_snippet(fallback, 80)
233   } else {
234      interesting.join(" | ")
235   }
236}
237
238fn build_synthetic_snippet(file: &ParsedFile) -> String {
239   let header_text = file
240      .header_lines
241      .iter()
242      .skip(1)
243      .find(|line| {
244         !line.starts_with("index ")
245            && !line.starts_with("--- ")
246            && !line.starts_with("+++ ")
247            && !line.trim().is_empty()
248      })
249      .cloned()
250      .unwrap_or_else(|| format!("whole-file change in {}", file.path));
251
252   truncate_snippet(&header_text, 80)
253}
254
255fn fnv1a_64(input: &str) -> String {
256   let mut hash = 0xcbf29ce484222325_u64;
257   for byte in input.as_bytes() {
258      hash ^= u64::from(*byte);
259      hash = hash.wrapping_mul(0x100000001b3);
260   }
261   format!("{hash:016x}")
262}
263
264fn build_semantic_key(path: &str, lines: &[String], fallback: &str) -> String {
265   let mut changed = Vec::new();
266   for line in lines {
267      if (line.starts_with('+') && !line.starts_with("+++"))
268         || (line.starts_with('-') && !line.starts_with("---"))
269      {
270         changed.push(line.clone());
271      }
272   }
273
274   let source = if changed.is_empty() {
275      fallback.to_string()
276   } else {
277      changed.join("\n")
278   };
279
280   format!("{path}:{}", fnv1a_64(&source))
281}
282
283pub fn build_compose_snapshot(diff: &str, stat: &str) -> Result<ComposeSnapshot> {
284   let mut files = Vec::new();
285   let mut current_file: Option<ParsedFile> = None;
286   let mut current_hunk: Option<ParsedHunk> = None;
287
288   for line in diff.lines() {
289      if line.starts_with("diff --git ") {
290         finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
291         current_file = Some(ParsedFile {
292            path:         parse_file_path(line)?,
293            header_lines: vec![line.to_string()],
294            hunks:        Vec::new(),
295            additions:    0,
296            deletions:    0,
297            is_binary:    false,
298         });
299         continue;
300      }
301
302      let Some(file) = &mut current_file else {
303         continue;
304      };
305
306      if line.starts_with("@@ ") {
307         finalize_current_hunk(file, &mut current_hunk);
308         let (old_start, old_count, new_start, new_count) =
309            parse_hunk_header(line).ok_or_else(|| {
310               CommitGenError::Other(format!("Failed to parse hunk header '{line}'"))
311            })?;
312         current_hunk = Some(ParsedHunk {
313            old_start,
314            old_count,
315            new_start,
316            new_count,
317            header: line.to_string(),
318            lines: vec![line.to_string()],
319         });
320         continue;
321      }
322
323      if let Some(hunk) = &mut current_hunk {
324         if line.starts_with('+') && !line.starts_with("+++") {
325            file.additions += 1;
326         } else if line.starts_with('-') && !line.starts_with("---") {
327            file.deletions += 1;
328         }
329
330         hunk.lines.push(line.to_string());
331         continue;
332      }
333
334      if line.starts_with("Binary files ") {
335         file.is_binary = true;
336      }
337      file.header_lines.push(line.to_string());
338   }
339
340   finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
341
342   let mut snapshot_files = Vec::new();
343   let mut snapshot_hunks = Vec::new();
344
345   for (file_index, file) in files.into_iter().enumerate() {
346      let file_id = format!("F{:03}", file_index + 1);
347      let patch_header = join_lines(&file.header_lines);
348      let mut full_patch = patch_header.clone();
349      let mut hunk_ids = Vec::new();
350
351      if file.hunks.is_empty() {
352         let hunk_id = format!("{file_id}-H001");
353         let snippet = build_synthetic_snippet(&file);
354         let semantic_key = build_semantic_key(&file.path, &file.header_lines, &snippet);
355         hunk_ids.push(hunk_id.clone());
356         snapshot_hunks.push(ComposeHunk {
357            hunk_id,
358            file_id: file_id.clone(),
359            path: file.path.clone(),
360            old_start: 0,
361            old_count: 0,
362            new_start: 0,
363            new_count: 0,
364            header: snippet.clone(),
365            raw_patch: String::new(),
366            snippet,
367            semantic_key,
368            synthetic: true,
369         });
370      } else {
371         for (hunk_index, hunk) in file.hunks.iter().enumerate() {
372            let hunk_id = format!("{file_id}-H{:03}", hunk_index + 1);
373            let raw_patch = join_lines(&hunk.lines);
374            let snippet = build_hunk_snippet(&hunk.lines, &hunk.header);
375            let semantic_key = build_semantic_key(&file.path, &hunk.lines, &snippet);
376
377            full_patch.push_str(&raw_patch);
378            hunk_ids.push(hunk_id.clone());
379            snapshot_hunks.push(ComposeHunk {
380               hunk_id,
381               file_id: file_id.clone(),
382               path: file.path.clone(),
383               old_start: hunk.old_start,
384               old_count: hunk.old_count,
385               new_start: hunk.new_start,
386               new_count: hunk.new_count,
387               header: hunk.header.clone(),
388               raw_patch,
389               snippet,
390               semantic_key,
391               synthetic: false,
392            });
393         }
394      }
395
396      let hunk_word = if hunk_ids.len() == 1 { "hunk" } else { "hunks" };
397      let summary = format!(
398         "{} (+{}/-{}, {} {})",
399         file.path,
400         file.additions,
401         file.deletions,
402         hunk_ids.len(),
403         hunk_word
404      );
405
406      snapshot_files.push(ComposeFile {
407         file_id,
408         path: file.path,
409         patch_header,
410         full_patch,
411         summary,
412         hunk_ids,
413         additions: file.additions,
414         deletions: file.deletions,
415         is_binary: file.is_binary,
416         synthetic_only: file.hunks.is_empty(),
417      });
418   }
419
420   Ok(ComposeSnapshot {
421      diff:  diff.to_string(),
422      stat:  stat.to_string(),
423      files: snapshot_files,
424      hunks: snapshot_hunks,
425   })
426}
427
428fn create_patch_for_file(file: &ComposeFile, hunks: &[&ComposeHunk]) -> String {
429   let mut patch = file.patch_header.clone();
430   for hunk in hunks {
431      patch.push_str(&hunk.raw_patch);
432   }
433   patch
434}
435
436fn selected_hunks_by_file<'a>(
437   snapshot: &'a ComposeSnapshot,
438   group: &ComposeExecutableGroup,
439) -> Result<BTreeMap<String, Vec<&'a ComposeHunk>>> {
440   if group.hunk_ids.is_empty() {
441      return Err(CommitGenError::Other(format!("Group {} has no assigned hunks", group.group_id)));
442   }
443
444   let mut selected_by_file: BTreeMap<String, Vec<&ComposeHunk>> = BTreeMap::new();
445   for hunk_id in &group.hunk_ids {
446      let hunk = snapshot.hunk_by_id(hunk_id).ok_or_else(|| {
447         CommitGenError::Other(format!(
448            "Group {} references unknown hunk id {hunk_id}",
449            group.group_id
450         ))
451      })?;
452      selected_by_file
453         .entry(hunk.file_id.clone())
454         .or_default()
455         .push(hunk);
456   }
457
458   Ok(selected_by_file)
459}
460
461fn ordered_selected_hunks<'a>(
462   file: &ComposeFile,
463   selected_for_file: &[&'a ComposeHunk],
464) -> Result<Vec<&'a ComposeHunk>> {
465   let ordered_hunks: Vec<&ComposeHunk> = file
466      .hunk_ids
467      .iter()
468      .filter_map(|hunk_id| {
469         selected_for_file
470            .iter()
471            .find(|hunk| hunk.hunk_id == *hunk_id)
472            .copied()
473      })
474      .collect();
475
476   if ordered_hunks.is_empty() {
477      return Err(CommitGenError::Other(format!("Selected no patchable hunks for {}", file.path)));
478   }
479
480   Ok(ordered_hunks)
481}
482
483fn selected_hunks_cover_file(file: &ComposeFile, selected_for_file: &[&ComposeHunk]) -> bool {
484   let selected_ids: HashSet<&str> = selected_for_file
485      .iter()
486      .map(|hunk| hunk.hunk_id.as_str())
487      .collect();
488   let file_hunk_ids: HashSet<&str> = file.hunk_ids.iter().map(String::as_str).collect();
489   selected_ids == file_hunk_ids
490}
491
492fn count_hunk_changes(hunk: &ComposeHunk) -> (usize, usize) {
493   let mut additions = 0_usize;
494   let mut deletions = 0_usize;
495
496   for line in hunk.raw_patch.lines() {
497      if line.starts_with('+') && !line.starts_with("+++") {
498         additions += 1;
499      } else if line.starts_with('-') && !line.starts_with("---") {
500         deletions += 1;
501      }
502   }
503
504   (additions, deletions)
505}
506
507fn push_stat_line(
508   stat: &mut String,
509   path: &str,
510   additions: usize,
511   deletions: usize,
512   is_binary: bool,
513) {
514   use std::fmt::Write;
515
516   if is_binary && additions == 0 && deletions == 0 {
517      writeln!(stat, " {path} | Bin").unwrap();
518      return;
519   }
520
521   let change_count = additions + deletions;
522   let pluses = "+".repeat(additions.min(50));
523   let minuses = "-".repeat(deletions.min(50));
524   writeln!(stat, " {path} | {change_count} {pluses}{minuses}").unwrap();
525}
526
527pub fn create_executable_group_patch(
528   snapshot: &ComposeSnapshot,
529   group: &ComposeExecutableGroup,
530) -> Result<ComposeGroupPatch> {
531   let selected_by_file = selected_hunks_by_file(snapshot, group)?;
532   let mut fallback_files = Vec::new();
533   let mut diff = String::new();
534   let mut stat = String::new();
535   let mut apply_patch = String::new();
536
537   for file in &snapshot.files {
538      let Some(selected_for_file) = selected_by_file.get(&file.file_id) else {
539         continue;
540      };
541
542      let ordered_hunks = ordered_selected_hunks(file, selected_for_file).map_err(|_| {
543         CommitGenError::Other(format!(
544            "Group {} selected no patchable hunks for {}",
545            group.group_id, file.path
546         ))
547      })?;
548
549      if file.synthetic_only || file.is_binary {
550         if selected_hunks_cover_file(file, selected_for_file) {
551            fallback_files.push(file.path.clone());
552            diff.push_str(&file.full_patch);
553            push_stat_line(&mut stat, &file.path, file.additions, file.deletions, file.is_binary);
554            continue;
555         }
556
557         return Err(CommitGenError::Other(format!(
558            "Group {} cannot partially stage unpatchable file {}",
559            group.group_id, file.path
560         )));
561      }
562
563      let file_patch = create_patch_for_file(file, &ordered_hunks);
564      let (additions, deletions) = ordered_hunks.iter().fold(
565         (0_usize, 0_usize),
566         |(total_additions, total_deletions), hunk| {
567            let (hunk_additions, hunk_deletions) = count_hunk_changes(hunk);
568            (total_additions + hunk_additions, total_deletions + hunk_deletions)
569         },
570      );
571      diff.push_str(&file_patch);
572      apply_patch.push_str(&file_patch);
573      push_stat_line(&mut stat, &file.path, additions, deletions, false);
574   }
575
576   fallback_files.sort();
577   fallback_files.dedup();
578
579   Ok(ComposeGroupPatch { diff, stat, apply_patch, fallback_files })
580}
581
582pub fn stage_executable_group(
583   snapshot: &ComposeSnapshot,
584   group: &ComposeExecutableGroup,
585   dir: &str,
586) -> Result<StageResult> {
587   let group_patch = create_executable_group_patch(snapshot, group)?;
588   let mut result = StageResult::EmptyPatch;
589
590   if !group_patch.fallback_files.is_empty() {
591      stage_files(&group_patch.fallback_files, dir)?;
592      result = result.combine(StageResult::Staged);
593   }
594
595   let patch_result = apply_patch_to_index(&group_patch.apply_patch, dir)?;
596   result = result.combine(patch_result);
597
598   Ok(result)
599}
600
601#[cfg(test)]
602mod tests {
603   use std::fs;
604
605   use tempfile::TempDir;
606
607   use super::*;
608   use crate::{
609      compose_types::ComposeExecutableGroup,
610      git::{get_compose_diff, get_compose_stat},
611      types::CommitType,
612   };
613
614   fn write_file(dir: &TempDir, path: &str, contents: &str) {
615      let full_path = dir.path().join(path);
616      if let Some(parent) = full_path.parent() {
617         fs::create_dir_all(parent).unwrap();
618      }
619      fs::write(full_path, contents).unwrap();
620   }
621
622   fn run_git(dir: &TempDir, args: &[&str]) -> String {
623      let output = git_command()
624         .args(args)
625         .current_dir(dir.path())
626         .output()
627         .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}"));
628
629      assert!(
630         output.status.success(),
631         "git {:?} failed: stdout={} stderr={}",
632         args,
633         String::from_utf8_lossy(&output.stdout),
634         String::from_utf8_lossy(&output.stderr)
635      );
636
637      String::from_utf8_lossy(&output.stdout).to_string()
638   }
639
640   fn init_repo() -> TempDir {
641      let dir = TempDir::new().unwrap();
642      run_git(&dir, &["init"]);
643      run_git(&dir, &["config", "user.name", "Compose Test"]);
644      run_git(&dir, &["config", "user.email", "compose@test.local"]);
645      run_git(&dir, &["config", "commit.gpgsign", "false"]);
646      dir
647   }
648
649   fn fixture_file_original() -> String {
650      [
651         "fn alpha() {",
652         "    println!(\"alpha\");",
653         "}",
654         "",
655         "// spacer 1",
656         "// spacer 2",
657         "// spacer 3",
658         "// spacer 4",
659         "// spacer 5",
660         "// spacer 6",
661         "// spacer 7",
662         "// spacer 8",
663         "fn beta() {",
664         "    println!(\"beta\");",
665         "}",
666         "",
667      ]
668      .join("\n")
669   }
670
671   fn fixture_file_stage_only() -> String {
672      fixture_file_original().replace("alpha", "alpha staged")
673   }
674
675   fn fixture_file_stage_and_unstaged() -> String {
676      fixture_file_stage_only().replace("beta", "beta unstaged")
677   }
678
679   fn fixture_file_two_hunks() -> String {
680      [
681         "fn alpha() {",
682         "    println!(\"alpha changed\");",
683         "}",
684         "",
685         "// spacer 1",
686         "// spacer 2",
687         "// spacer 3",
688         "// spacer 4",
689         "// spacer 5",
690         "// spacer 6",
691         "// spacer 7",
692         "// spacer 8",
693         "fn beta() {",
694         "    println!(\"beta changed\");",
695         "}",
696         "",
697      ]
698      .join("\n")
699   }
700
701   fn commit_all(dir: &TempDir, message: &str) {
702      run_git(dir, &["add", "."]);
703      run_git(dir, &["commit", "-m", message]);
704   }
705
706   fn staged_diff(dir: &TempDir) -> String {
707      run_git(dir, &["diff", "--cached"])
708   }
709
710   #[test]
711   fn test_build_compose_snapshot_stable_ids() {
712      let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
713index 1111111..2222222 100644
714--- a/src/lib.rs
715+++ b/src/lib.rs
716@@ -1,3 +1,3 @@
717-fn alpha() {
718+fn alpha_changed() {
719     println!("alpha");
720 }
721diff --git a/tests/lib.rs b/tests/lib.rs
722index 3333333..4444444 100644
723--- a/tests/lib.rs
724+++ b/tests/lib.rs
725@@ -10,3 +10,4 @@
726 fn test_it() {
727+    assert!(true);
728 }
729"#;
730
731      let stat = " src/lib.rs | 2 +-\n tests/lib.rs | 1 +\n";
732      let first = build_compose_snapshot(diff, stat).unwrap();
733      let second = build_compose_snapshot(diff, stat).unwrap();
734
735      assert_eq!(first.files.len(), 2);
736      assert_eq!(
737         first
738            .files
739            .iter()
740            .map(|file| file.file_id.clone())
741            .collect::<Vec<_>>(),
742         second
743            .files
744            .iter()
745            .map(|file| file.file_id.clone())
746            .collect::<Vec<_>>()
747      );
748      assert_eq!(
749         first
750            .hunks
751            .iter()
752            .map(|hunk| hunk.hunk_id.clone())
753            .collect::<Vec<_>>(),
754         second
755            .hunks
756            .iter()
757            .map(|hunk| hunk.hunk_id.clone())
758            .collect::<Vec<_>>()
759      );
760   }
761
762   #[test]
763   fn test_get_compose_diff_merges_staged_unstaged_and_untracked() {
764      let dir = init_repo();
765      write_file(&dir, "src/lib.rs", &fixture_file_original());
766      commit_all(&dir, "initial");
767
768      write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
769      run_git(&dir, &["add", "src/lib.rs"]);
770      write_file(&dir, "src/lib.rs", &fixture_file_stage_and_unstaged());
771      write_file(&dir, "notes.txt", "new untracked file\n");
772
773      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
774      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
775      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
776
777      assert_eq!(snapshot.files.len(), 2);
778      assert!(snapshot.file_by_path("src/lib.rs").is_some());
779      assert!(snapshot.file_by_path("notes.txt").is_some());
780
781      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
782      assert!(
783         source_file.hunk_ids.len() >= 2,
784         "expected staged + unstaged edits in one file to produce multiple hunks"
785      );
786   }
787
788   #[test]
789   fn test_stage_executable_group_partial_hunk_from_one_file() {
790      let dir = init_repo();
791      write_file(&dir, "src/lib.rs", &fixture_file_original());
792      commit_all(&dir, "initial");
793      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
794
795      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
796      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
797      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
798      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
799      assert_eq!(source_file.hunk_ids.len(), 2);
800
801      reset_staging(dir.path().to_str().unwrap()).unwrap();
802      let group = ComposeExecutableGroup {
803         group_id:     "G1".to_string(),
804         commit_type:  CommitType::new("refactor").unwrap(),
805         scope:        None,
806         file_ids:     vec![source_file.file_id.clone()],
807         rationale:    "first hunk".to_string(),
808         dependencies: vec![],
809         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
810      };
811      stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
812
813      let staged = staged_diff(&dir);
814      assert!(staged.contains("alpha changed"));
815      assert!(!staged.contains("beta changed"));
816   }
817
818   #[test]
819   fn test_stage_executable_group_across_sequential_commits_same_file() {
820      let dir = init_repo();
821      write_file(&dir, "src/lib.rs", &fixture_file_original());
822      commit_all(&dir, "initial");
823      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
824
825      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
826      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
827      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
828      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
829      assert_eq!(source_file.hunk_ids.len(), 2);
830
831      let first_group = ComposeExecutableGroup {
832         group_id:     "G1".to_string(),
833         commit_type:  CommitType::new("refactor").unwrap(),
834         scope:        None,
835         file_ids:     vec![source_file.file_id.clone()],
836         rationale:    "first hunk".to_string(),
837         dependencies: vec![],
838         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
839      };
840      let second_group = ComposeExecutableGroup {
841         group_id:     "G2".to_string(),
842         commit_type:  CommitType::new("refactor").unwrap(),
843         scope:        None,
844         file_ids:     vec![source_file.file_id.clone()],
845         rationale:    "second hunk".to_string(),
846         dependencies: vec![],
847         hunk_ids:     vec![source_file.hunk_ids[1].clone()],
848      };
849
850      reset_staging(dir.path().to_str().unwrap()).unwrap();
851      stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
852      run_git(&dir, &["commit", "-m", "first"]);
853
854      stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
855      let staged = staged_diff(&dir);
856      assert!(staged.contains("beta changed"));
857      assert!(!staged.contains("alpha changed"));
858   }
859
860   #[test]
861   fn test_create_executable_group_patch_derives_diff_without_staging() {
862      let dir = init_repo();
863      write_file(&dir, "src/lib.rs", &fixture_file_original());
864      commit_all(&dir, "initial");
865      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
866
867      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
868      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
869      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
870      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
871      let group = ComposeExecutableGroup {
872         group_id:     "G1".to_string(),
873         commit_type:  CommitType::new("refactor").unwrap(),
874         scope:        None,
875         file_ids:     vec![source_file.file_id.clone()],
876         rationale:    "first hunk".to_string(),
877         dependencies: vec![],
878         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
879      };
880
881      reset_staging(dir.path().to_str().unwrap()).unwrap();
882      let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
883
884      assert!(staged_diff(&dir).trim().is_empty());
885      assert!(group_patch.diff.contains("alpha changed"));
886      assert!(!group_patch.diff.contains("beta changed"));
887      assert!(group_patch.stat.contains("src/lib.rs | 2 +-"));
888   }
889
890   #[test]
891   fn test_stage_executable_groups_ignore_unplanned_files_between_commits() {
892      let dir = init_repo();
893      write_file(&dir, "src/a.rs", "fn a() {}\n");
894      write_file(&dir, "src/b.rs", "fn b() {}\n");
895      commit_all(&dir, "initial");
896      write_file(&dir, "src/a.rs", "fn a_changed() {}\n");
897      write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
898
899      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
900      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
901      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
902      let first_file = snapshot.file_by_path("src/a.rs").unwrap();
903      let second_file = snapshot.file_by_path("src/b.rs").unwrap();
904      let first_group = ComposeExecutableGroup {
905         group_id:     "G1".to_string(),
906         commit_type:  CommitType::new("refactor").unwrap(),
907         scope:        None,
908         file_ids:     vec![first_file.file_id.clone()],
909         rationale:    "first file".to_string(),
910         dependencies: vec![],
911         hunk_ids:     first_file.hunk_ids.clone(),
912      };
913      let second_group = ComposeExecutableGroup {
914         group_id:     "G2".to_string(),
915         commit_type:  CommitType::new("refactor").unwrap(),
916         scope:        None,
917         file_ids:     vec![second_file.file_id.clone()],
918         rationale:    "second file".to_string(),
919         dependencies: vec![],
920         hunk_ids:     second_file.hunk_ids.clone(),
921      };
922
923      reset_staging(dir.path().to_str().unwrap()).unwrap();
924      assert_eq!(
925         stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap(),
926         StageResult::Staged
927      );
928      run_git(&dir, &["commit", "-m", "first"]);
929      write_file(&dir, "Dockerfile", "FROM scratch\n");
930
931      assert_eq!(
932         stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap(),
933         StageResult::Staged
934      );
935      let staged = staged_diff(&dir);
936      assert!(staged.contains("b_changed"));
937      assert!(!staged.contains("Dockerfile"));
938      run_git(&dir, &["commit", "-m", "second"]);
939
940      assert!(
941         get_compose_diff(dir.path().to_str().unwrap())
942            .unwrap()
943            .contains("Dockerfile")
944      );
945   }
946
947   #[test]
948   fn test_stage_executable_group_ignores_same_file_local_edit_between_commits() {
949      let dir = init_repo();
950      write_file(&dir, "src/lib.rs", &fixture_file_original());
951      commit_all(&dir, "initial");
952      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
953
954      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
955      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
956      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
957      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
958      let first_group = ComposeExecutableGroup {
959         group_id:     "G1".to_string(),
960         commit_type:  CommitType::new("refactor").unwrap(),
961         scope:        None,
962         file_ids:     vec![source_file.file_id.clone()],
963         rationale:    "first hunk".to_string(),
964         dependencies: vec![],
965         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
966      };
967      let second_group = ComposeExecutableGroup {
968         group_id:     "G2".to_string(),
969         commit_type:  CommitType::new("refactor").unwrap(),
970         scope:        None,
971         file_ids:     vec![source_file.file_id.clone()],
972         rationale:    "second hunk".to_string(),
973         dependencies: vec![],
974         hunk_ids:     vec![source_file.hunk_ids[1].clone()],
975      };
976
977      reset_staging(dir.path().to_str().unwrap()).unwrap();
978      stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
979      run_git(&dir, &["commit", "-m", "first"]);
980      write_file(
981         &dir,
982         "src/lib.rs",
983         &fixture_file_two_hunks().replace("// spacer 4", "// local edit"),
984      );
985
986      stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
987      let staged = staged_diff(&dir);
988      assert!(staged.contains("beta changed"));
989      assert!(!staged.contains("local edit"));
990   }
991
992   #[test]
993   fn test_stage_executable_group_noops_when_snapshot_patch_already_applied() {
994      let dir = init_repo();
995      write_file(&dir, "src/lib.rs", &fixture_file_original());
996      commit_all(&dir, "initial");
997      write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
998
999      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1000      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1001      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1002      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1003      let group = ComposeExecutableGroup {
1004         group_id:     "G1".to_string(),
1005         commit_type:  CommitType::new("refactor").unwrap(),
1006         scope:        None,
1007         file_ids:     vec![source_file.file_id.clone()],
1008         rationale:    "all hunks".to_string(),
1009         dependencies: vec![],
1010         hunk_ids:     source_file.hunk_ids.clone(),
1011      };
1012
1013      reset_staging(dir.path().to_str().unwrap()).unwrap();
1014      let first_result =
1015         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1016      assert_eq!(first_result, StageResult::Staged);
1017      run_git(&dir, &["commit", "-m", "applied"]);
1018
1019      let second_result =
1020         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1021      assert_eq!(second_result, StageResult::AlreadyApplied);
1022      assert!(staged_diff(&dir).trim().is_empty());
1023   }
1024
1025   #[test]
1026   fn test_stage_executable_group_reuses_snapshot_patch_not_worktree_contents() {
1027      let dir = init_repo();
1028      write_file(&dir, "README.md", "initial\n");
1029      commit_all(&dir, "initial");
1030      write_file(&dir, "notes.txt", "planned\n");
1031
1032      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1033      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1034      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1035      let notes_file = snapshot.file_by_path("notes.txt").unwrap();
1036      let group = ComposeExecutableGroup {
1037         group_id:     "G1".to_string(),
1038         commit_type:  CommitType::new("docs").unwrap(),
1039         scope:        None,
1040         file_ids:     vec![notes_file.file_id.clone()],
1041         rationale:    "new notes".to_string(),
1042         dependencies: vec![],
1043         hunk_ids:     notes_file.hunk_ids.clone(),
1044      };
1045
1046      reset_staging(dir.path().to_str().unwrap()).unwrap();
1047      let planned_result =
1048         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1049      assert_eq!(planned_result, StageResult::Staged);
1050      let planned_staged = staged_diff(&dir);
1051      assert!(planned_staged.contains("+planned"));
1052      assert!(!planned_staged.contains("local edit"));
1053
1054      reset_staging(dir.path().to_str().unwrap()).unwrap();
1055      write_file(&dir, "notes.txt", "planned\nlocal edit\n");
1056      let reused_result =
1057         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1058      assert_eq!(reused_result, StageResult::Staged);
1059      let reused_staged = staged_diff(&dir);
1060
1061      assert_eq!(reused_staged, planned_staged);
1062      assert!(!reused_staged.contains("local edit"));
1063   }
1064}