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
54fn 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
84pub 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
103pub 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
125pub 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}