1use std::{
2 borrow::Cow,
3 collections::{BTreeMap, HashSet},
4 path::Path,
5};
6
7use crate::{
8 compose_types::{ComposeExecutableGroup, ComposeFile, ComposeHunk, ComposeSnapshot},
9 error::{CommitGenError, Result},
10 git::{git_command, git_command_with_index},
11};
12
13#[derive(Debug, Clone)]
14struct ParsedHunk {
15 old_start: usize,
16 old_count: usize,
17 new_start: usize,
18 new_count: usize,
19 header: String,
20 lines: Vec<String>,
21}
22
23#[derive(Debug, Clone)]
24struct ParsedFile {
25 path: String,
26 header_lines: Vec<String>,
27 hunks: Vec<ParsedHunk>,
28 additions: usize,
29 deletions: usize,
30 is_binary: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ComposeGroupPatch {
35 pub diff: String,
36 pub stat: String,
37 apply_patches: Vec<FilePatch>,
38 fallback_files: Vec<String>,
39 index_blobs: Vec<IndexBlob>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43struct FilePatch {
44 path: String,
45 patch: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49struct IndexBlob {
50 path: String,
51 mode: String,
52 object: IndexObject,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56enum IndexObject {
57 BlobContents(String),
58 BlobBytes(Vec<u8>),
59 ExistingObject(String),
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum StageResult {
64 Staged,
65 AlreadyApplied,
66 EmptyPatch,
67}
68
69impl StageResult {
70 const fn combine(self, other: Self) -> Self {
71 match (self, other) {
72 (Self::Staged, _) | (_, Self::Staged) => Self::Staged,
73 (Self::AlreadyApplied, _) | (_, Self::AlreadyApplied) => Self::AlreadyApplied,
74 (Self::EmptyPatch, Self::EmptyPatch) => Self::EmptyPatch,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81enum FilePatchOutcome {
82 Staged,
83 AlreadyApplied,
84 Empty,
85 Failed(String),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct SkippedFile {
93 pub path: String,
94 pub reason: String,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ComposeStageOutcome {
101 pub result: StageResult,
102 pub skipped: Vec<SkippedFile>,
103}
104
105fn git_command_for_index(index_file: Option<&Path>) -> std::process::Command {
107 if let Some(index_file) = index_file {
108 git_command_with_index(index_file)
109 } else {
110 git_command()
111 }
112}
113
114fn run_git_apply(
115 patch: &str,
116 args: &[&str],
117 dir: &str,
118 index_file: Option<&Path>,
119) -> Result<std::process::Output> {
120 let mut child = git_command_for_index(index_file)
121 .args(args)
122 .current_dir(dir)
123 .stdin(std::process::Stdio::piped())
124 .stdout(std::process::Stdio::piped())
125 .stderr(std::process::Stdio::piped())
126 .spawn()
127 .map_err(|e| CommitGenError::git(format!("Failed to spawn git apply: {e}")))?;
128
129 if let Some(mut stdin) = child.stdin.take() {
130 use std::io::Write;
131
132 stdin
133 .write_all(patch.as_bytes())
134 .map_err(|e| CommitGenError::git(format!("Failed to write patch: {e}")))?;
135 }
136
137 child
138 .wait_with_output()
139 .map_err(|e| CommitGenError::git(format!("Failed to wait for git apply: {e}")))
140}
141
142fn patch_is_already_applied_to_index(
143 patch: &str,
144 dir: &str,
145 index_file: Option<&Path>,
146) -> Result<bool> {
147 let output = run_git_apply(
148 patch,
149 &["apply", "--cached", "--reverse", "--check", "--recount"],
150 dir,
151 index_file,
152 )?;
153 Ok(output.status.success())
154}
155
156fn apply_file_patch_to_index(
162 patch: &str,
163 dir: &str,
164 index_file: Option<&Path>,
165) -> Result<FilePatchOutcome> {
166 if patch.trim().is_empty() {
167 return Ok(FilePatchOutcome::Empty);
168 }
169
170 if patch_is_already_applied_to_index(patch, dir, index_file)? {
171 return Ok(FilePatchOutcome::AlreadyApplied);
172 }
173
174 let output =
175 run_git_apply(patch, &["apply", "--cached", "--3way", "--recount"], dir, index_file)?;
176 if output.status.success() {
177 return Ok(FilePatchOutcome::Staged);
178 }
179
180 Ok(FilePatchOutcome::Failed(String::from_utf8_lossy(&output.stderr).trim().to_string()))
181}
182
183fn restore_index_path_to_head(path: &str, dir: &str, index_file: Option<&Path>) -> Result<()> {
188 let output = git_command_for_index(index_file)
189 .args(["reset", "-q", "HEAD", "--"])
190 .arg(path)
191 .current_dir(dir)
192 .output()
193 .map_err(|e| CommitGenError::git(format!("Failed to reset index entry {path}: {e}")))?;
194
195 if !output.status.success() {
196 let stderr = String::from_utf8_lossy(&output.stderr);
197 return Err(CommitGenError::git(format!("git reset failed for {path}: {stderr}")));
198 }
199
200 Ok(())
201}
202
203fn resolve_blob_oid(oid: &str, path: &str, dir: &str) -> Result<String> {
205 let output = git_command()
206 .args(["rev-parse", "--verify", "--quiet"])
207 .arg(format!("{oid}^{{blob}}"))
208 .current_dir(dir)
209 .output()
210 .map_err(|e| CommitGenError::git(format!("Failed to resolve base blob for {path}: {e}")))?;
211
212 let full = String::from_utf8_lossy(&output.stdout).trim().to_string();
213 if !output.status.success() || full.is_empty() {
214 return Err(CommitGenError::git(format!(
215 "Cannot resolve base blob {oid} for {path}: object not found"
216 )));
217 }
218
219 Ok(full)
220}
221
222fn cat_file_blob(oid: &str, path: &str, dir: &str) -> Result<Vec<u8>> {
224 let output = git_command()
225 .args(["cat-file", "blob", oid])
226 .current_dir(dir)
227 .output()
228 .map_err(|e| CommitGenError::git(format!("Failed to read base blob for {path}: {e}")))?;
229
230 if !output.status.success() {
231 let stderr = String::from_utf8_lossy(&output.stderr);
232 return Err(CommitGenError::git(format!("git cat-file blob failed for {path}: {stderr}")));
233 }
234
235 Ok(output.stdout)
236}
237
238fn resolve_base_blob(file: &ComposeFile, dir: &str) -> Result<(Vec<u8>, String)> {
242 let index_line = file
243 .patch_header
244 .lines()
245 .find(|line| line.starts_with("index "));
246
247 let base_oid = index_line.and_then(|line| {
248 let rest = line.strip_prefix("index ")?;
249 let range = rest.split_whitespace().next()?;
250 range.split_once("..").map(|(base, _)| base)
251 });
252
253 match base_oid {
254 Some(oid) if !oid.is_empty() && oid.bytes().any(|byte| byte != b'0') => {
255 let full = resolve_blob_oid(oid, &file.path, dir)?;
256 let bytes = cat_file_blob(&full, &file.path, dir)?;
257 let mode = index_line
258 .and_then(|line| line.strip_prefix("index "))
259 .and_then(|rest| rest.split_whitespace().nth(1))
260 .map(str::to_string)
261 .or_else(|| {
262 file.patch_header.lines().find_map(|line| {
263 line
264 .strip_prefix("old mode ")
265 .map(|mode| mode.trim().to_string())
266 })
267 })
268 .unwrap_or_else(|| "100644".to_string());
269 Ok((bytes, mode))
270 },
271 _ => {
272 let mode = new_file_mode(file).unwrap_or("100644").to_string();
273 Ok((Vec::new(), mode))
274 },
275 }
276}
277
278fn split_lines_keep_eol(data: &[u8]) -> Vec<&[u8]> {
281 let mut lines = Vec::new();
282 let mut start = 0usize;
283 while start < data.len() {
284 if let Some(rel) = data[start..].iter().position(|&byte| byte == b'\n') {
285 lines.push(&data[start..=start + rel]);
286 start += rel + 1;
287 } else {
288 lines.push(&data[start..]);
289 break;
290 }
291 }
292 lines
293}
294
295fn dominant_eol(lines: &[&[u8]]) -> &'static [u8] {
298 let mut crlf = 0usize;
299 let mut lf = 0usize;
300 for line in lines {
301 if line.ends_with(b"\r\n") {
302 crlf += 1;
303 } else if line.ends_with(b"\n") {
304 lf += 1;
305 }
306 }
307 if crlf > 0 && crlf >= lf {
308 b"\r\n"
309 } else {
310 b"\n"
311 }
312}
313
314fn strip_trailing_eol(buf: &mut Vec<u8>) {
316 if buf.last() == Some(&b'\n') {
317 buf.pop();
318 if buf.last() == Some(&b'\r') {
319 buf.pop();
320 }
321 }
322}
323
324fn splice_hunks_into_base(base: &[u8], hunks: &[&ComposeHunk]) -> Vec<u8> {
330 let base_lines = split_lines_keep_eol(base);
331 let eol = dominant_eol(&base_lines);
332
333 let mut ordered: Vec<&&ComposeHunk> = hunks.iter().collect();
334 ordered.sort_by_key(|hunk| hunk.old_start);
335
336 let mut out: Vec<u8> = Vec::with_capacity(base.len());
337 let mut cursor = 0usize; for hunk in ordered {
340 let start = hunk.old_start.saturating_sub(1);
341 while cursor < start && cursor < base_lines.len() {
342 out.extend_from_slice(base_lines[cursor]);
343 cursor += 1;
344 }
345
346 let mut prev: u8 = 0;
347 for (idx, line) in diff_lines_preserve_cr(&hunk.raw_patch).enumerate() {
348 if idx == 0 {
349 continue;
351 }
352 let bytes = line.as_bytes();
353 if bytes.first() == Some(&b'\\') {
354 if prev == b'+' || prev == b' ' {
358 strip_trailing_eol(&mut out);
359 }
360 continue;
361 }
362 match bytes.first() {
363 Some(b'-') => {
364 cursor += 1;
365 prev = b'-';
366 },
367 Some(b'+') => {
368 let mut content = &bytes[1..];
369 if content.last() == Some(&b'\r') {
370 content = &content[..content.len() - 1];
371 }
372 out.extend_from_slice(content);
373 out.extend_from_slice(eol);
374 prev = b'+';
375 },
376 _ => {
377 if cursor < base_lines.len() {
379 out.extend_from_slice(base_lines[cursor]);
380 cursor += 1;
381 }
382 prev = b' ';
383 },
384 }
385 }
386 }
387
388 while cursor < base_lines.len() {
389 out.extend_from_slice(base_lines[cursor]);
390 cursor += 1;
391 }
392
393 out
394}
395
396#[tracing::instrument(target = "lgit", name = "patch.force_stage_file_from_base", skip_all, fields(dir, file_id, hunk_count = selected_hunk_ids.len()))]
405pub fn force_stage_file_from_base(
406 snapshot: &ComposeSnapshot,
407 file_id: &str,
408 selected_hunk_ids: &[String],
409 dir: &str,
410) -> Result<()> {
411 force_stage_file_from_base_with_index(snapshot, file_id, selected_hunk_ids, dir, None)
412}
413
414#[tracing::instrument(target = "lgit", name = "patch.force_stage_file_from_base_in_index", skip_all, fields(dir, file_id, hunk_count = selected_hunk_ids.len(), index = %index_file.display()))]
415pub fn force_stage_file_from_base_in_index(
416 snapshot: &ComposeSnapshot,
417 file_id: &str,
418 selected_hunk_ids: &[String],
419 dir: &str,
420 index_file: &Path,
421) -> Result<()> {
422 force_stage_file_from_base_with_index(
423 snapshot,
424 file_id,
425 selected_hunk_ids,
426 dir,
427 Some(index_file),
428 )
429}
430
431fn force_stage_file_from_base_with_index(
432 snapshot: &ComposeSnapshot,
433 file_id: &str,
434 selected_hunk_ids: &[String],
435 dir: &str,
436 index_file: Option<&Path>,
437) -> Result<()> {
438 let file = snapshot
439 .file_by_id(file_id)
440 .ok_or_else(|| CommitGenError::Other(format!("Unknown compose file id {file_id}")))?;
441
442 let ordered: Vec<&ComposeHunk> = file
443 .hunk_ids
444 .iter()
445 .filter(|hunk_id| {
446 selected_hunk_ids
447 .iter()
448 .any(|selected| selected == *hunk_id)
449 })
450 .filter_map(|hunk_id| snapshot.hunk_by_id(hunk_id))
451 .filter(|hunk| !hunk.raw_patch.is_empty())
452 .collect();
453
454 if ordered.is_empty() {
455 return Ok(());
456 }
457
458 restore_index_path_to_head(&file.path, dir, index_file)?;
462 let (base_bytes, mode) = resolve_base_blob(file, dir)?;
463 let target = splice_hunks_into_base(&base_bytes, &ordered);
464 let blob = IndexBlob { path: file.path.clone(), mode, object: IndexObject::BlobBytes(target) };
465 stage_index_blob(&blob, dir, index_file)?;
466
467 Ok(())
468}
469
470#[tracing::instrument(target = "lgit", name = "patch.stage_files", skip_all, fields(dir, file_count = files.len()))]
472pub fn stage_files(files: &[String], dir: &str) -> Result<()> {
473 stage_files_with_index(files, dir, None)
474}
475
476fn stage_files_with_index(files: &[String], dir: &str, index_file: Option<&Path>) -> Result<()> {
477 if files.is_empty() {
478 return Ok(());
479 }
480
481 let output = git_command_for_index(index_file)
482 .arg("add")
483 .arg("--")
484 .args(files)
485 .current_dir(dir)
486 .output()
487 .map_err(|e| CommitGenError::git(format!("Failed to stage files: {e}")))?;
488
489 if !output.status.success() {
490 let stderr = String::from_utf8_lossy(&output.stderr);
491 return Err(CommitGenError::git(format!("git add failed: {stderr}")));
492 }
493
494 Ok(())
495}
496
497fn hash_blob_bytes(contents: &[u8], path: &str, dir: &str) -> Result<String> {
498 let mut child = git_command()
499 .args(["hash-object", "-w", "--stdin"])
500 .current_dir(dir)
501 .stdin(std::process::Stdio::piped())
502 .stdout(std::process::Stdio::piped())
503 .stderr(std::process::Stdio::piped())
504 .spawn()
505 .map_err(|e| CommitGenError::git(format!("Failed to spawn git hash-object: {e}")))?;
506
507 {
508 let Some(mut stdin) = child.stdin.take() else {
509 return Err(CommitGenError::git("Failed to open git hash-object stdin".to_string()));
510 };
511
512 use std::io::Write;
513
514 stdin
515 .write_all(contents)
516 .map_err(|e| CommitGenError::git(format!("Failed to write blob for {path}: {e}")))?;
517 }
518
519 let output = child
520 .wait_with_output()
521 .map_err(|e| CommitGenError::git(format!("Failed to wait for git hash-object: {e}")))?;
522
523 if !output.status.success() {
524 let stderr = String::from_utf8_lossy(&output.stderr);
525 return Err(CommitGenError::git(format!("git hash-object failed for {path}: {stderr}")));
526 }
527
528 let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
529 if oid.is_empty() {
530 return Err(CommitGenError::git(format!("git hash-object returned empty oid for {path}")));
531 }
532
533 Ok(oid)
534}
535
536fn index_blob_oid<'a>(blob: &'a IndexBlob, dir: &str) -> Result<Cow<'a, str>> {
537 match &blob.object {
538 IndexObject::BlobContents(contents) => {
539 Ok(Cow::Owned(hash_blob_bytes(contents.as_bytes(), &blob.path, dir)?))
540 },
541 IndexObject::BlobBytes(bytes) => Ok(Cow::Owned(hash_blob_bytes(bytes, &blob.path, dir)?)),
542 IndexObject::ExistingObject(oid) => Ok(Cow::Borrowed(oid.as_str())),
543 }
544}
545
546fn index_entry_matches(
547 path: &str,
548 mode: &str,
549 oid: &str,
550 dir: &str,
551 index_file: Option<&Path>,
552) -> Result<bool> {
553 let output = git_command_for_index(index_file)
554 .args(["ls-files", "-s", "--"])
555 .arg(path)
556 .current_dir(dir)
557 .output()
558 .map_err(|e| CommitGenError::git(format!("Failed to inspect index entry {path}: {e}")))?;
559
560 if !output.status.success() {
561 let stderr = String::from_utf8_lossy(&output.stderr);
562 return Err(CommitGenError::git(format!("git ls-files failed for {path}: {stderr}")));
563 }
564
565 let stdout = String::from_utf8_lossy(&output.stdout);
566 let Some(line) = stdout.lines().next() else {
567 return Ok(false);
568 };
569 let mut parts = line.split_whitespace();
570 Ok(parts.next() == Some(mode) && parts.next() == Some(oid))
571}
572
573fn stage_index_blob(blob: &IndexBlob, dir: &str, index_file: Option<&Path>) -> Result<StageResult> {
574 let oid = index_blob_oid(blob, dir)?;
575 if index_entry_matches(&blob.path, &blob.mode, oid.as_ref(), dir, index_file)? {
576 return Ok(StageResult::AlreadyApplied);
577 }
578
579 let cacheinfo = format!("{},{},{}", blob.mode, oid, blob.path);
580 let output = git_command_for_index(index_file)
581 .args(["update-index", "--add", "--cacheinfo"])
582 .arg(cacheinfo)
583 .current_dir(dir)
584 .output()
585 .map_err(|e| CommitGenError::git(format!("Failed to stage blob {}: {e}", blob.path)))?;
586
587 if !output.status.success() {
588 let stderr = String::from_utf8_lossy(&output.stderr);
589 return Err(CommitGenError::git(format!(
590 "git update-index failed for {}: {stderr}",
591 blob.path
592 )));
593 }
594
595 Ok(StageResult::Staged)
596}
597
598#[tracing::instrument(target = "lgit", name = "patch.reset_staging", skip_all, fields(dir))]
600pub fn reset_staging(dir: &str) -> Result<()> {
601 let output = git_command()
602 .args(["reset", "HEAD"])
603 .current_dir(dir)
604 .output()
605 .map_err(|e| CommitGenError::git(format!("Failed to reset staging: {e}")))?;
606
607 if !output.status.success() {
608 let stderr = String::from_utf8_lossy(&output.stderr);
609 return Err(CommitGenError::git(format!("git reset HEAD failed: {stderr}")));
610 }
611
612 Ok(())
613}
614
615fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
616 let trimmed = header.trim();
617 if !trimmed.starts_with("@@") {
618 return None;
619 }
620
621 let after_first = trimmed.strip_prefix("@@")?;
622 let middle = after_first.split("@@").next()?.trim();
623 let parts: Vec<&str> = middle.split_whitespace().collect();
624 if parts.len() < 2 {
625 return None;
626 }
627
628 let old_part = parts[0].strip_prefix('-')?;
629 let new_part = parts[1].strip_prefix('+')?;
630
631 let parse_range = |s: &str| -> Option<(usize, usize)> {
632 if let Some((start, count)) = s.split_once(',') {
633 Some((start.parse().ok()?, count.parse().ok()?))
634 } else {
635 Some((s.parse().ok()?, 1))
636 }
637 };
638
639 let (old_start, old_count) = parse_range(old_part)?;
640 let (new_start, new_count) = parse_range(new_part)?;
641 Some((old_start, old_count, new_start, new_count))
642}
643
644fn parse_file_path(diff_header: &str) -> Result<String> {
645 diff_header
646 .split_whitespace()
647 .nth(3)
648 .and_then(|part| part.strip_prefix("b/"))
649 .map(str::to_string)
650 .ok_or_else(|| {
651 CommitGenError::Other(format!("Failed to parse file path from '{diff_header}'"))
652 })
653}
654
655fn finalize_current_hunk(file: &mut ParsedFile, current_hunk: &mut Option<ParsedHunk>) {
656 if let Some(hunk) = current_hunk.take() {
657 file.hunks.push(hunk);
658 }
659}
660
661fn finalize_current_file(
662 files: &mut Vec<ParsedFile>,
663 current_file: &mut Option<ParsedFile>,
664 current_hunk: &mut Option<ParsedHunk>,
665) {
666 if let Some(mut file) = current_file.take() {
667 finalize_current_hunk(&mut file, current_hunk);
668 files.push(file);
669 }
670}
671
672fn join_lines(lines: &[String]) -> String {
673 if lines.is_empty() {
674 String::new()
675 } else {
676 let mut joined = lines.join("\n");
677 joined.push('\n');
678 joined
679 }
680}
681
682fn diff_lines_preserve_cr(input: &str) -> impl Iterator<Item = &str> {
683 input
684 .split_inclusive('\n')
685 .map(|line| line.strip_suffix('\n').unwrap_or(line))
686}
687
688fn truncate_snippet(snippet: &str, max_chars: usize) -> String {
689 let trimmed = snippet.trim();
690 if trimmed.chars().count() <= max_chars {
691 return trimmed.to_string();
692 }
693
694 let mut truncated = trimmed.chars().take(max_chars).collect::<String>();
695 truncated.push_str("...");
696 truncated
697}
698
699fn build_hunk_snippet(lines: &[String], fallback: &str) -> String {
700 let interesting: Vec<String> = lines
701 .iter()
702 .skip(1)
703 .filter(|line| line.starts_with('+') || line.starts_with('-'))
704 .take(3)
705 .map(|line| truncate_snippet(line.trim_start_matches(['+', '-']), 80))
706 .collect();
707
708 if interesting.is_empty() {
709 truncate_snippet(fallback, 80)
710 } else {
711 interesting.join(" | ")
712 }
713}
714
715fn build_synthetic_snippet(file: &ParsedFile) -> String {
716 let header_text = file
717 .header_lines
718 .iter()
719 .skip(1)
720 .find(|line| {
721 !line.starts_with("index ")
722 && !line.starts_with("--- ")
723 && !line.starts_with("+++ ")
724 && !line.trim().is_empty()
725 })
726 .cloned()
727 .unwrap_or_else(|| format!("whole-file change in {}", file.path));
728
729 truncate_snippet(&header_text, 80)
730}
731
732fn fnv1a_64(input: &str) -> String {
733 let mut hash = 0xcbf29ce484222325_u64;
734 for byte in input.as_bytes() {
735 hash ^= u64::from(*byte);
736 hash = hash.wrapping_mul(0x100000001b3);
737 }
738 format!("{hash:016x}")
739}
740
741fn build_semantic_key(path: &str, lines: &[String], fallback: &str) -> String {
742 let mut changed = Vec::new();
743 for line in lines {
744 if (line.starts_with('+') && !line.starts_with("+++"))
745 || (line.starts_with('-') && !line.starts_with("---"))
746 {
747 changed.push(line.clone());
748 }
749 }
750
751 let source = if changed.is_empty() {
752 fallback.to_string()
753 } else {
754 changed.join("\n")
755 };
756
757 format!("{path}:{}", fnv1a_64(&source))
758}
759
760#[tracing::instrument(target = "lgit", name = "patch.build_compose_snapshot", skip_all, fields(diff_bytes = diff.len(), stat_bytes = stat.len()))]
761pub fn build_compose_snapshot(diff: &str, stat: &str) -> Result<ComposeSnapshot> {
762 let mut files = Vec::new();
763 let mut current_file: Option<ParsedFile> = None;
764 let mut current_hunk: Option<ParsedHunk> = None;
765
766 for line in diff_lines_preserve_cr(diff) {
767 if line.starts_with("diff --git ") {
768 finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
769 current_file = Some(ParsedFile {
770 path: parse_file_path(line)?,
771 header_lines: vec![line.to_string()],
772 hunks: Vec::new(),
773 additions: 0,
774 deletions: 0,
775 is_binary: false,
776 });
777 continue;
778 }
779
780 let Some(file) = &mut current_file else {
781 continue;
782 };
783
784 if line.starts_with("@@ ") {
785 finalize_current_hunk(file, &mut current_hunk);
786 let (old_start, old_count, new_start, new_count) =
787 parse_hunk_header(line).ok_or_else(|| {
788 CommitGenError::Other(format!("Failed to parse hunk header '{line}'"))
789 })?;
790 current_hunk = Some(ParsedHunk {
791 old_start,
792 old_count,
793 new_start,
794 new_count,
795 header: line.to_string(),
796 lines: vec![line.to_string()],
797 });
798 continue;
799 }
800
801 if let Some(hunk) = &mut current_hunk {
802 if line.starts_with('+') {
803 file.additions += 1;
804 } else if line.starts_with('-') {
805 file.deletions += 1;
806 }
807
808 hunk.lines.push(line.to_string());
809 continue;
810 }
811
812 if line.starts_with("Binary files ") {
813 file.is_binary = true;
814 }
815 file.header_lines.push(line.to_string());
816 }
817
818 finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
819
820 let mut snapshot_files = Vec::new();
821 let mut snapshot_hunks = Vec::new();
822
823 for (file_index, file) in files.into_iter().enumerate() {
824 let file_id = format!("F{:03}", file_index + 1);
825 let patch_header = join_lines(&file.header_lines);
826 let mut full_patch = patch_header.clone();
827 let mut hunk_ids = Vec::new();
828
829 if file.hunks.is_empty() {
830 let hunk_id = format!("{file_id}-H001");
831 let snippet = build_synthetic_snippet(&file);
832 let semantic_key = build_semantic_key(&file.path, &file.header_lines, &snippet);
833 hunk_ids.push(hunk_id.clone());
834 snapshot_hunks.push(ComposeHunk {
835 hunk_id,
836 file_id: file_id.clone(),
837 path: file.path.clone(),
838 old_start: 0,
839 old_count: 0,
840 new_start: 0,
841 new_count: 0,
842 header: snippet.clone(),
843 raw_patch: String::new(),
844 snippet,
845 semantic_key,
846 synthetic: true,
847 });
848 } else {
849 for (hunk_index, hunk) in file.hunks.iter().enumerate() {
850 let hunk_id = format!("{file_id}-H{:03}", hunk_index + 1);
851 let raw_patch = join_lines(&hunk.lines);
852 let snippet = build_hunk_snippet(&hunk.lines, &hunk.header);
853 let semantic_key = build_semantic_key(&file.path, &hunk.lines, &snippet);
854
855 full_patch.push_str(&raw_patch);
856 hunk_ids.push(hunk_id.clone());
857 snapshot_hunks.push(ComposeHunk {
858 hunk_id,
859 file_id: file_id.clone(),
860 path: file.path.clone(),
861 old_start: hunk.old_start,
862 old_count: hunk.old_count,
863 new_start: hunk.new_start,
864 new_count: hunk.new_count,
865 header: hunk.header.clone(),
866 raw_patch,
867 snippet,
868 semantic_key,
869 synthetic: false,
870 });
871 }
872 }
873
874 let hunk_word = if hunk_ids.len() == 1 { "hunk" } else { "hunks" };
875 let summary = format!(
876 "{} (+{}/-{}, {} {})",
877 file.path,
878 file.additions,
879 file.deletions,
880 hunk_ids.len(),
881 hunk_word
882 );
883
884 snapshot_files.push(ComposeFile {
885 file_id,
886 path: file.path,
887 patch_header,
888 full_patch,
889 summary,
890 hunk_ids,
891 additions: file.additions,
892 deletions: file.deletions,
893 is_binary: file.is_binary,
894 synthetic_only: file.hunks.is_empty(),
895 });
896 }
897
898 Ok(ComposeSnapshot {
899 diff: diff.to_string(),
900 stat: stat.to_string(),
901 files: snapshot_files,
902 hunks: snapshot_hunks,
903 })
904}
905
906fn create_patch_for_file(file: &ComposeFile, hunks: &[&ComposeHunk]) -> String {
907 let mut patch = file.patch_header.clone();
908 for hunk in hunks {
909 patch.push_str(&hunk.raw_patch);
910 }
911 patch
912}
913
914fn selected_hunks_by_file<'a>(
915 snapshot: &'a ComposeSnapshot,
916 group: &ComposeExecutableGroup,
917) -> Result<BTreeMap<String, Vec<&'a ComposeHunk>>> {
918 if group.hunk_ids.is_empty() {
919 return Err(CommitGenError::Other(format!("Group {} has no assigned hunks", group.group_id)));
920 }
921
922 let mut selected_by_file: BTreeMap<String, Vec<&ComposeHunk>> = BTreeMap::new();
923 for hunk_id in &group.hunk_ids {
924 let hunk = snapshot.hunk_by_id(hunk_id).ok_or_else(|| {
925 CommitGenError::Other(format!(
926 "Group {} references unknown hunk id {hunk_id}",
927 group.group_id
928 ))
929 })?;
930 selected_by_file
931 .entry(hunk.file_id.clone())
932 .or_default()
933 .push(hunk);
934 }
935
936 Ok(selected_by_file)
937}
938
939fn ordered_selected_hunks<'a>(
940 file: &ComposeFile,
941 selected_for_file: &[&'a ComposeHunk],
942) -> Result<Vec<&'a ComposeHunk>> {
943 let ordered_hunks: Vec<&ComposeHunk> = file
944 .hunk_ids
945 .iter()
946 .filter_map(|hunk_id| {
947 selected_for_file
948 .iter()
949 .find(|hunk| hunk.hunk_id == *hunk_id)
950 .copied()
951 })
952 .collect();
953
954 if ordered_hunks.is_empty() {
955 return Err(CommitGenError::Other(format!("Selected no patchable hunks for {}", file.path)));
956 }
957
958 Ok(ordered_hunks)
959}
960
961fn selected_hunks_cover_file(file: &ComposeFile, selected_for_file: &[&ComposeHunk]) -> bool {
962 let selected_ids: HashSet<&str> = selected_for_file
963 .iter()
964 .map(|hunk| hunk.hunk_id.as_str())
965 .collect();
966 let file_hunk_ids: HashSet<&str> = file.hunk_ids.iter().map(String::as_str).collect();
967 selected_ids == file_hunk_ids
968}
969
970fn count_hunk_changes(hunk: &ComposeHunk) -> (usize, usize) {
971 let mut additions = 0_usize;
972 let mut deletions = 0_usize;
973
974 for line in hunk.raw_patch.lines() {
975 if line.starts_with('+') {
976 additions += 1;
977 } else if line.starts_with('-') {
978 deletions += 1;
979 }
980 }
981
982 (additions, deletions)
983}
984
985fn push_stat_line(
986 stat: &mut String,
987 path: &str,
988 additions: usize,
989 deletions: usize,
990 is_binary: bool,
991) {
992 use std::fmt::Write;
993
994 if is_binary && additions == 0 && deletions == 0 {
995 writeln!(stat, " {path} | Bin").unwrap();
996 return;
997 }
998
999 let change_count = additions + deletions;
1000 let pluses = "+".repeat(additions.min(50));
1001 let minuses = "-".repeat(deletions.min(50));
1002 writeln!(stat, " {path} | {change_count} {pluses}{minuses}").unwrap();
1003}
1004
1005fn new_file_mode(file: &ComposeFile) -> Option<&str> {
1006 file
1007 .patch_header
1008 .lines()
1009 .find_map(|line| line.strip_prefix("new file mode ").map(str::trim))
1010}
1011
1012fn validate_new_file_mode(file: &ComposeFile) -> Result<String> {
1013 let mode = new_file_mode(file).unwrap_or("100644");
1014 if matches!(mode, "100644" | "100755" | "120000" | "160000") {
1015 Ok(mode.to_string())
1016 } else {
1017 Err(CommitGenError::Other(format!("Invalid new file mode {mode:?} for {}", file.path)))
1018 }
1019}
1020
1021fn materialize_new_file_contents(hunks: &[&ComposeHunk]) -> String {
1022 let mut contents = String::new();
1023 let mut last_emitted_line_had_newline = false;
1024
1025 for hunk in hunks {
1026 for line in diff_lines_preserve_cr(&hunk.raw_patch) {
1027 if line.starts_with("@@") {
1028 last_emitted_line_had_newline = false;
1029 continue;
1030 }
1031
1032 if line == r"\ No newline at end of file" {
1033 if last_emitted_line_had_newline {
1034 contents.pop();
1035 last_emitted_line_had_newline = false;
1036 }
1037 continue;
1038 }
1039
1040 if let Some(added) = line.strip_prefix('+') {
1041 contents.push_str(added);
1042 contents.push('\n');
1043 last_emitted_line_had_newline = true;
1044 } else if let Some(context) = line.strip_prefix(' ') {
1045 contents.push_str(context);
1046 contents.push('\n');
1047 last_emitted_line_had_newline = true;
1048 } else {
1049 last_emitted_line_had_newline = false;
1050 }
1051 }
1052 }
1053
1054 contents
1055}
1056
1057fn new_file_index_oid(file: &ComposeFile) -> Option<&str> {
1058 file.patch_header.lines().find_map(|line| {
1059 let index_range = line.strip_prefix("index ")?;
1060 let (_, new_oid) = index_range.split_once("..")?;
1061 new_oid.split_whitespace().next()
1062 })
1063}
1064
1065fn validate_git_object_id(oid: &str, file: &ComposeFile) -> Result<String> {
1066 let oid = oid.trim();
1067 if !oid.is_empty()
1068 && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
1069 && oid.bytes().any(|byte| byte != b'0')
1070 {
1071 Ok(oid.to_string())
1072 } else {
1073 Err(CommitGenError::Other(format!("Invalid gitlink object id {oid:?} for {}", file.path)))
1074 }
1075}
1076
1077fn materialize_gitlink_oid(file: &ComposeFile, hunks: &[&ComposeHunk]) -> Result<String> {
1078 let contents = materialize_new_file_contents(hunks);
1079 if let Some(oid) = contents.lines().find_map(|line| {
1080 line
1081 .strip_prefix("Subproject commit ")
1082 .and_then(|rest| rest.split_whitespace().next())
1083 }) {
1084 return validate_git_object_id(oid, file);
1085 }
1086
1087 if let Some(oid) = new_file_index_oid(file) {
1088 return validate_git_object_id(oid, file);
1089 }
1090
1091 Err(CommitGenError::Other(format!("Missing gitlink object id for {}", file.path)))
1092}
1093
1094fn new_file_index_blob(file: &ComposeFile, hunks: &[&ComposeHunk]) -> Result<IndexBlob> {
1095 let mode = validate_new_file_mode(file)?;
1096 let object = if mode == "160000" {
1097 IndexObject::ExistingObject(materialize_gitlink_oid(file, hunks)?)
1098 } else {
1099 IndexObject::BlobContents(materialize_new_file_contents(hunks))
1100 };
1101
1102 Ok(IndexBlob { path: file.path.clone(), mode, object })
1103}
1104
1105#[tracing::instrument(target = "lgit", name = "patch.create_executable_group_patch", skip_all, fields(group_id = %group.group_id, file_count = group.file_ids.len(), hunk_count = group.hunk_ids.len()))]
1106pub fn create_executable_group_patch(
1107 snapshot: &ComposeSnapshot,
1108 group: &ComposeExecutableGroup,
1109) -> Result<ComposeGroupPatch> {
1110 let selected_by_file = selected_hunks_by_file(snapshot, group)?;
1111 let mut fallback_files = Vec::new();
1112 let mut diff = String::new();
1113 let mut stat = String::new();
1114 let mut apply_patches: Vec<FilePatch> = Vec::new();
1115 let mut index_blobs = Vec::new();
1116
1117 for file in &snapshot.files {
1118 let Some(selected_for_file) = selected_by_file.get(&file.file_id) else {
1119 continue;
1120 };
1121
1122 let ordered_hunks = ordered_selected_hunks(file, selected_for_file).map_err(|_| {
1123 CommitGenError::Other(format!(
1124 "Group {} selected no patchable hunks for {}",
1125 group.group_id, file.path
1126 ))
1127 })?;
1128
1129 if file.synthetic_only || file.is_binary {
1130 if selected_hunks_cover_file(file, selected_for_file) {
1131 if file.synthetic_only && !file.is_binary && new_file_mode(file).is_some() {
1132 index_blobs.push(new_file_index_blob(file, &ordered_hunks)?);
1133 } else {
1134 fallback_files.push(file.path.clone());
1135 }
1136 diff.push_str(&file.full_patch);
1137 push_stat_line(&mut stat, &file.path, file.additions, file.deletions, file.is_binary);
1138 continue;
1139 }
1140
1141 return Err(CommitGenError::Other(format!(
1142 "Group {} cannot partially stage unpatchable file {}",
1143 group.group_id, file.path
1144 )));
1145 }
1146
1147 let file_patch = create_patch_for_file(file, &ordered_hunks);
1148 let (additions, deletions) = ordered_hunks.iter().fold(
1149 (0_usize, 0_usize),
1150 |(total_additions, total_deletions), hunk| {
1151 let (hunk_additions, hunk_deletions) = count_hunk_changes(hunk);
1152 (total_additions + hunk_additions, total_deletions + hunk_deletions)
1153 },
1154 );
1155 diff.push_str(&file_patch);
1156 if new_file_mode(file).is_some() {
1157 if selected_hunks_cover_file(file, selected_for_file) {
1160 index_blobs.push(new_file_index_blob(file, &ordered_hunks)?);
1161 } else {
1162 apply_patches.push(FilePatch { path: file.path.clone(), patch: file_patch });
1163 }
1164 } else if selected_hunks_cover_file(file, selected_for_file) {
1165 fallback_files.push(file.path.clone());
1169 } else {
1170 apply_patches.push(FilePatch { path: file.path.clone(), patch: file_patch });
1173 }
1174 push_stat_line(&mut stat, &file.path, additions, deletions, false);
1175 }
1176
1177 fallback_files.sort();
1178 fallback_files.dedup();
1179
1180 Ok(ComposeGroupPatch { diff, stat, apply_patches, fallback_files, index_blobs })
1181}
1182
1183#[tracing::instrument(target = "lgit", name = "patch.stage_executable_group", skip_all, fields(dir, group_id = %group.group_id))]
1184pub fn stage_executable_group(
1185 snapshot: &ComposeSnapshot,
1186 group: &ComposeExecutableGroup,
1187 dir: &str,
1188) -> Result<ComposeStageOutcome> {
1189 stage_executable_group_with_index(snapshot, group, dir, None)
1190}
1191
1192#[tracing::instrument(target = "lgit", name = "patch.stage_executable_group_in_index", skip_all, fields(dir, group_id = %group.group_id, index = %index_file.display()))]
1193pub fn stage_executable_group_in_index(
1194 snapshot: &ComposeSnapshot,
1195 group: &ComposeExecutableGroup,
1196 dir: &str,
1197 index_file: &Path,
1198) -> Result<ComposeStageOutcome> {
1199 stage_executable_group_with_index(snapshot, group, dir, Some(index_file))
1200}
1201
1202fn stage_executable_group_with_index(
1203 snapshot: &ComposeSnapshot,
1204 group: &ComposeExecutableGroup,
1205 dir: &str,
1206 index_file: Option<&Path>,
1207) -> Result<ComposeStageOutcome> {
1208 let group_patch = create_executable_group_patch(snapshot, group)?;
1209 let mut result = StageResult::EmptyPatch;
1210 let mut skipped = Vec::new();
1211
1212 for file_patch in &group_patch.apply_patches {
1213 match apply_file_patch_to_index(&file_patch.patch, dir, index_file)? {
1214 FilePatchOutcome::Staged => result = result.combine(StageResult::Staged),
1215 FilePatchOutcome::AlreadyApplied => {
1216 result = result.combine(StageResult::AlreadyApplied);
1217 },
1218 FilePatchOutcome::Empty => result = result.combine(StageResult::EmptyPatch),
1219 FilePatchOutcome::Failed(reason) => {
1220 restore_index_path_to_head(&file_patch.path, dir, index_file)?;
1223 skipped.push(SkippedFile { path: file_patch.path.clone(), reason });
1224 },
1225 }
1226 }
1227
1228 if !group_patch.fallback_files.is_empty() {
1229 stage_files_with_index(&group_patch.fallback_files, dir, index_file)?;
1230 result = result.combine(StageResult::Staged);
1231 }
1232
1233 for blob in &group_patch.index_blobs {
1234 result = result.combine(stage_index_blob(blob, dir, index_file)?);
1235 }
1236
1237 Ok(ComposeStageOutcome { result, skipped })
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use std::fs;
1243
1244 use tempfile::TempDir;
1245
1246 use super::*;
1247 use crate::{
1248 compose_types::ComposeExecutableGroup,
1249 git::{TempGitIndex, get_compose_diff, get_compose_stat, read_tree_into_index},
1250 types::CommitType,
1251 };
1252
1253 fn write_file(dir: &TempDir, path: &str, contents: &str) {
1254 let full_path = dir.path().join(path);
1255 if let Some(parent) = full_path.parent() {
1256 fs::create_dir_all(parent).unwrap();
1257 }
1258 fs::write(full_path, contents).unwrap();
1259 }
1260
1261 fn run_git(dir: &TempDir, args: &[&str]) -> String {
1262 let output = git_command()
1263 .args(args)
1264 .current_dir(dir.path())
1265 .output()
1266 .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}"));
1267
1268 assert!(
1269 output.status.success(),
1270 "git {:?} failed: stdout={} stderr={}",
1271 args,
1272 String::from_utf8_lossy(&output.stdout),
1273 String::from_utf8_lossy(&output.stderr)
1274 );
1275
1276 String::from_utf8_lossy(&output.stdout).to_string()
1277 }
1278
1279 fn init_repo() -> TempDir {
1280 let dir = TempDir::new().unwrap();
1281 run_git(&dir, &["init"]);
1282 run_git(&dir, &["config", "user.name", "Compose Test"]);
1283 run_git(&dir, &["config", "user.email", "compose@test.local"]);
1284 run_git(&dir, &["config", "commit.gpgsign", "false"]);
1285 dir
1286 }
1287
1288 fn fixture_file_original() -> String {
1289 [
1290 "fn alpha() {",
1291 " println!(\"alpha\");",
1292 "}",
1293 "",
1294 "// spacer 1",
1295 "// spacer 2",
1296 "// spacer 3",
1297 "// spacer 4",
1298 "// spacer 5",
1299 "// spacer 6",
1300 "// spacer 7",
1301 "// spacer 8",
1302 "fn beta() {",
1303 " println!(\"beta\");",
1304 "}",
1305 "",
1306 ]
1307 .join("\n")
1308 }
1309
1310 fn fixture_file_stage_only() -> String {
1311 fixture_file_original().replace("alpha", "alpha staged")
1312 }
1313
1314 fn fixture_file_stage_and_unstaged() -> String {
1315 fixture_file_stage_only().replace("beta", "beta unstaged")
1316 }
1317
1318 fn fixture_file_two_hunks() -> String {
1319 [
1320 "fn alpha() {",
1321 " println!(\"alpha changed\");",
1322 "}",
1323 "",
1324 "// spacer 1",
1325 "// spacer 2",
1326 "// spacer 3",
1327 "// spacer 4",
1328 "// spacer 5",
1329 "// spacer 6",
1330 "// spacer 7",
1331 "// spacer 8",
1332 "fn beta() {",
1333 " println!(\"beta changed\");",
1334 "}",
1335 "",
1336 ]
1337 .join("\n")
1338 }
1339
1340 fn commit_all(dir: &TempDir, message: &str) {
1341 run_git(dir, &["add", "."]);
1342 run_git(dir, &["commit", "-m", message]);
1343 }
1344
1345 fn staged_diff(dir: &TempDir) -> String {
1346 run_git(dir, &["diff", "--cached"])
1347 }
1348
1349 fn staged_diff_in_index(dir: &TempDir, index: &TempGitIndex) -> String {
1350 let output = crate::git::git_command_with_index(index.path())
1351 .args(["diff", "--cached"])
1352 .current_dir(dir.path())
1353 .output()
1354 .unwrap();
1355 assert!(
1356 output.status.success(),
1357 "git diff --cached with temp index failed: {}",
1358 String::from_utf8_lossy(&output.stderr)
1359 );
1360 String::from_utf8_lossy(&output.stdout).to_string()
1361 }
1362
1363 #[test]
1364 fn test_build_compose_snapshot_stable_ids() {
1365 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1366index 1111111..2222222 100644
1367--- a/src/lib.rs
1368+++ b/src/lib.rs
1369@@ -1,3 +1,3 @@
1370-fn alpha() {
1371+fn alpha_changed() {
1372 println!("alpha");
1373 }
1374diff --git a/tests/lib.rs b/tests/lib.rs
1375index 3333333..4444444 100644
1376--- a/tests/lib.rs
1377+++ b/tests/lib.rs
1378@@ -10,3 +10,4 @@
1379 fn test_it() {
1380+ assert!(true);
1381 }
1382"#;
1383
1384 let stat = " src/lib.rs | 2 +-\n tests/lib.rs | 1 +\n";
1385 let first = build_compose_snapshot(diff, stat).unwrap();
1386 let second = build_compose_snapshot(diff, stat).unwrap();
1387
1388 assert_eq!(first.files.len(), 2);
1389 assert_eq!(
1390 first
1391 .files
1392 .iter()
1393 .map(|file| file.file_id.clone())
1394 .collect::<Vec<_>>(),
1395 second
1396 .files
1397 .iter()
1398 .map(|file| file.file_id.clone())
1399 .collect::<Vec<_>>()
1400 );
1401 assert_eq!(
1402 first
1403 .hunks
1404 .iter()
1405 .map(|hunk| hunk.hunk_id.clone())
1406 .collect::<Vec<_>>(),
1407 second
1408 .hunks
1409 .iter()
1410 .map(|hunk| hunk.hunk_id.clone())
1411 .collect::<Vec<_>>()
1412 );
1413 }
1414
1415 #[test]
1416 fn test_get_compose_diff_merges_staged_unstaged_and_untracked() {
1417 let dir = init_repo();
1418 write_file(&dir, "src/lib.rs", &fixture_file_original());
1419 commit_all(&dir, "initial");
1420
1421 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1422 run_git(&dir, &["add", "src/lib.rs"]);
1423 write_file(&dir, "src/lib.rs", &fixture_file_stage_and_unstaged());
1424 write_file(&dir, "notes.txt", "new untracked file\n");
1425
1426 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1427 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1428 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1429
1430 assert_eq!(snapshot.files.len(), 2);
1431 assert!(snapshot.file_by_path("src/lib.rs").is_some());
1432 assert!(snapshot.file_by_path("notes.txt").is_some());
1433
1434 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1435 assert!(
1436 source_file.hunk_ids.len() >= 2,
1437 "expected staged + unstaged edits in one file to produce multiple hunks"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_stage_executable_group_partial_hunk_from_one_file() {
1443 let dir = init_repo();
1444 write_file(&dir, "src/lib.rs", &fixture_file_original());
1445 commit_all(&dir, "initial");
1446 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1447
1448 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1449 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1450 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1451 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1452 assert_eq!(source_file.hunk_ids.len(), 2);
1453
1454 reset_staging(dir.path().to_str().unwrap()).unwrap();
1455 let group = ComposeExecutableGroup {
1456 group_id: "G1".to_string(),
1457 commit_type: CommitType::new("refactor").unwrap(),
1458 scope: None,
1459 file_ids: vec![source_file.file_id.clone()],
1460 rationale: "first hunk".to_string(),
1461 dependencies: vec![],
1462 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1463 };
1464 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1465
1466 let staged = staged_diff(&dir);
1467 assert!(staged.contains("alpha changed"));
1468 assert!(!staged.contains("beta changed"));
1469 }
1470
1471 #[test]
1472 fn test_stage_executable_group_across_sequential_commits_same_file() {
1473 let dir = init_repo();
1474 write_file(&dir, "src/lib.rs", &fixture_file_original());
1475 commit_all(&dir, "initial");
1476 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1477
1478 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1479 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1480 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1481 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1482 assert_eq!(source_file.hunk_ids.len(), 2);
1483
1484 let first_group = ComposeExecutableGroup {
1485 group_id: "G1".to_string(),
1486 commit_type: CommitType::new("refactor").unwrap(),
1487 scope: None,
1488 file_ids: vec![source_file.file_id.clone()],
1489 rationale: "first hunk".to_string(),
1490 dependencies: vec![],
1491 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1492 };
1493 let second_group = ComposeExecutableGroup {
1494 group_id: "G2".to_string(),
1495 commit_type: CommitType::new("refactor").unwrap(),
1496 scope: None,
1497 file_ids: vec![source_file.file_id.clone()],
1498 rationale: "second hunk".to_string(),
1499 dependencies: vec![],
1500 hunk_ids: vec![source_file.hunk_ids[1].clone()],
1501 };
1502
1503 reset_staging(dir.path().to_str().unwrap()).unwrap();
1504 stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
1505 run_git(&dir, &["commit", "-m", "first"]);
1506
1507 stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
1508 let staged = staged_diff(&dir);
1509 assert!(staged.contains("beta changed"));
1510 assert!(!staged.contains("alpha changed"));
1511 }
1512
1513 #[test]
1514 fn test_create_executable_group_patch_derives_diff_without_staging() {
1515 let dir = init_repo();
1516 write_file(&dir, "src/lib.rs", &fixture_file_original());
1517 commit_all(&dir, "initial");
1518 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1519
1520 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1521 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1522 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1523 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1524 let group = ComposeExecutableGroup {
1525 group_id: "G1".to_string(),
1526 commit_type: CommitType::new("refactor").unwrap(),
1527 scope: None,
1528 file_ids: vec![source_file.file_id.clone()],
1529 rationale: "first hunk".to_string(),
1530 dependencies: vec![],
1531 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1532 };
1533
1534 reset_staging(dir.path().to_str().unwrap()).unwrap();
1535 let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
1536
1537 assert!(staged_diff(&dir).trim().is_empty());
1538 assert!(group_patch.diff.contains("alpha changed"));
1539 assert!(!group_patch.diff.contains("beta changed"));
1540 assert!(group_patch.stat.contains("src/lib.rs | 2 +-"));
1541 }
1542
1543 #[test]
1544 fn test_stage_executable_groups_ignore_unplanned_files_between_commits() {
1545 let dir = init_repo();
1546 write_file(&dir, "src/a.rs", "fn a() {}\n");
1547 write_file(&dir, "src/b.rs", "fn b() {}\n");
1548 commit_all(&dir, "initial");
1549 write_file(&dir, "src/a.rs", "fn a_changed() {}\n");
1550 write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
1551
1552 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1553 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1554 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1555 let first_file = snapshot.file_by_path("src/a.rs").unwrap();
1556 let second_file = snapshot.file_by_path("src/b.rs").unwrap();
1557 let first_group = ComposeExecutableGroup {
1558 group_id: "G1".to_string(),
1559 commit_type: CommitType::new("refactor").unwrap(),
1560 scope: None,
1561 file_ids: vec![first_file.file_id.clone()],
1562 rationale: "first file".to_string(),
1563 dependencies: vec![],
1564 hunk_ids: first_file.hunk_ids.clone(),
1565 };
1566 let second_group = ComposeExecutableGroup {
1567 group_id: "G2".to_string(),
1568 commit_type: CommitType::new("refactor").unwrap(),
1569 scope: None,
1570 file_ids: vec![second_file.file_id.clone()],
1571 rationale: "second file".to_string(),
1572 dependencies: vec![],
1573 hunk_ids: second_file.hunk_ids.clone(),
1574 };
1575
1576 reset_staging(dir.path().to_str().unwrap()).unwrap();
1577 assert_eq!(
1578 stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap())
1579 .unwrap()
1580 .result,
1581 StageResult::Staged
1582 );
1583 run_git(&dir, &["commit", "-m", "first"]);
1584 write_file(&dir, "Dockerfile", "FROM scratch\n");
1585
1586 assert_eq!(
1587 stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap())
1588 .unwrap()
1589 .result,
1590 StageResult::Staged
1591 );
1592 let staged = staged_diff(&dir);
1593 assert!(staged.contains("b_changed"));
1594 assert!(!staged.contains("Dockerfile"));
1595 run_git(&dir, &["commit", "-m", "second"]);
1596
1597 assert!(
1598 get_compose_diff(dir.path().to_str().unwrap())
1599 .unwrap()
1600 .contains("Dockerfile")
1601 );
1602 }
1603
1604 #[test]
1605 fn test_stage_executable_group_ignores_same_file_local_edit_between_commits() {
1606 let dir = init_repo();
1607 write_file(&dir, "src/lib.rs", &fixture_file_original());
1608 commit_all(&dir, "initial");
1609 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1610
1611 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1612 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1613 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1614 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1615 let first_group = ComposeExecutableGroup {
1616 group_id: "G1".to_string(),
1617 commit_type: CommitType::new("refactor").unwrap(),
1618 scope: None,
1619 file_ids: vec![source_file.file_id.clone()],
1620 rationale: "first hunk".to_string(),
1621 dependencies: vec![],
1622 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1623 };
1624 let second_group = ComposeExecutableGroup {
1625 group_id: "G2".to_string(),
1626 commit_type: CommitType::new("refactor").unwrap(),
1627 scope: None,
1628 file_ids: vec![source_file.file_id.clone()],
1629 rationale: "second hunk".to_string(),
1630 dependencies: vec![],
1631 hunk_ids: vec![source_file.hunk_ids[1].clone()],
1632 };
1633
1634 reset_staging(dir.path().to_str().unwrap()).unwrap();
1635 stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
1636 run_git(&dir, &["commit", "-m", "first"]);
1637 write_file(
1638 &dir,
1639 "src/lib.rs",
1640 &fixture_file_two_hunks().replace("// spacer 4", "// local edit"),
1641 );
1642
1643 stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
1644 let staged = staged_diff(&dir);
1645 assert!(staged.contains("beta changed"));
1646 assert!(!staged.contains("local edit"));
1647 }
1648
1649 #[test]
1650 fn test_stage_executable_group_noops_when_snapshot_patch_already_applied() {
1651 let dir = init_repo();
1652 write_file(&dir, "src/lib.rs", &fixture_file_original());
1653 commit_all(&dir, "initial");
1654 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1655
1656 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1657 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1658 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1659 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1660 let group = ComposeExecutableGroup {
1661 group_id: "G1".to_string(),
1662 commit_type: CommitType::new("refactor").unwrap(),
1663 scope: None,
1664 file_ids: vec![source_file.file_id.clone()],
1665 rationale: "all hunks".to_string(),
1666 dependencies: vec![],
1667 hunk_ids: source_file.hunk_ids.clone(),
1668 };
1669
1670 reset_staging(dir.path().to_str().unwrap()).unwrap();
1671 let first_result =
1672 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1673 assert_eq!(first_result.result, StageResult::Staged);
1674 run_git(&dir, &["commit", "-m", "applied"]);
1675
1676 let second_result =
1679 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1680 assert_eq!(second_result.result, StageResult::Staged);
1681 assert!(staged_diff(&dir).trim().is_empty());
1682 }
1683
1684 #[test]
1685 fn test_stage_executable_group_reuses_snapshot_patch_not_worktree_contents() {
1686 let dir = init_repo();
1687 write_file(&dir, "README.md", "initial\n");
1688 commit_all(&dir, "initial");
1689 write_file(&dir, "notes.txt", "planned\n");
1690
1691 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1692 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1693 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1694 let notes_file = snapshot.file_by_path("notes.txt").unwrap();
1695 let group = ComposeExecutableGroup {
1696 group_id: "G1".to_string(),
1697 commit_type: CommitType::new("docs").unwrap(),
1698 scope: None,
1699 file_ids: vec![notes_file.file_id.clone()],
1700 rationale: "new notes".to_string(),
1701 dependencies: vec![],
1702 hunk_ids: notes_file.hunk_ids.clone(),
1703 };
1704
1705 reset_staging(dir.path().to_str().unwrap()).unwrap();
1706 let planned_result =
1707 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1708 assert_eq!(planned_result.result, StageResult::Staged);
1709 let planned_staged = staged_diff(&dir);
1710 assert!(planned_staged.contains("+planned"));
1711 assert!(!planned_staged.contains("local edit"));
1712
1713 reset_staging(dir.path().to_str().unwrap()).unwrap();
1714 write_file(&dir, "notes.txt", "planned\nlocal edit\n");
1715 let reused_result =
1716 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1717 assert_eq!(reused_result.result, StageResult::Staged);
1718 let reused_staged = staged_diff(&dir);
1719
1720 assert_eq!(reused_staged, planned_staged);
1721 assert!(!reused_staged.contains("local edit"));
1722 }
1723
1724 #[test]
1725 fn test_stage_executable_group_materializes_new_file_from_snapshot() {
1726 let dir = init_repo();
1727 write_file(&dir, "README.md", "initial\n");
1728 commit_all(&dir, "initial");
1729
1730 let diff = r"diff --git a/notes.txt b/notes.txt
1731new file mode 100644
1732index 0000000..0000000
1733--- /dev/null
1734+++ b/notes.txt
1735@@ -1,1 +1,3 @@
1736-old
1737+old
1738+new
1739+++literal plus
1740";
1741 let stat = " notes.txt | 4 +++-\n";
1742 let snapshot = build_compose_snapshot(diff, stat).unwrap();
1743 let notes_file = snapshot.file_by_path("notes.txt").unwrap();
1744 let group = ComposeExecutableGroup {
1745 group_id: "G1".to_string(),
1746 commit_type: CommitType::new("docs").unwrap(),
1747 scope: None,
1748 file_ids: vec![notes_file.file_id.clone()],
1749 rationale: "new notes".to_string(),
1750 dependencies: vec![],
1751 hunk_ids: notes_file.hunk_ids.clone(),
1752 };
1753
1754 write_file(&dir, "notes.txt", "worktree edit\n");
1755 reset_staging(dir.path().to_str().unwrap()).unwrap();
1756 let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1757
1758 assert_eq!(result.result, StageResult::Staged);
1759 let staged = staged_diff(&dir);
1760 assert!(staged.contains("+old"));
1761 assert!(staged.contains("+new"));
1762 assert!(staged.contains("+++literal plus"));
1763 assert!(!staged.contains("worktree edit"));
1764 let second_result =
1765 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1766 assert_eq!(second_result.result, StageResult::AlreadyApplied);
1767 }
1768
1769 #[test]
1770 fn test_stage_executable_group_materializes_empty_new_file_from_snapshot() {
1771 let dir = init_repo();
1772 write_file(&dir, "README.md", "initial\n");
1773 commit_all(&dir, "initial");
1774
1775 let diff = r"diff --git a/empty.txt b/empty.txt
1776new file mode 100644
1777index 0000000..0000000
1778--- /dev/null
1779+++ b/empty.txt
1780";
1781 let stat = " empty.txt | 0\n";
1782 let snapshot = build_compose_snapshot(diff, stat).unwrap();
1783 let empty_file = snapshot.file_by_path("empty.txt").unwrap();
1784 let group = ComposeExecutableGroup {
1785 group_id: "G1".to_string(),
1786 commit_type: CommitType::new("docs").unwrap(),
1787 scope: None,
1788 file_ids: vec![empty_file.file_id.clone()],
1789 rationale: "empty notes".to_string(),
1790 dependencies: vec![],
1791 hunk_ids: empty_file.hunk_ids.clone(),
1792 };
1793
1794 write_file(&dir, "empty.txt", "worktree edit\n");
1795 reset_staging(dir.path().to_str().unwrap()).unwrap();
1796 let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1797
1798 assert_eq!(result.result, StageResult::Staged);
1799 let staged = staged_diff(&dir);
1800 assert!(staged.contains("new file mode 100644"));
1801 assert!(!staged.contains("worktree edit"));
1802 }
1803
1804 #[test]
1805 fn test_stage_executable_group_materializes_new_gitlink_from_snapshot() {
1806 let dir = init_repo();
1807 write_file(&dir, "README.md", "initial\n");
1808 commit_all(&dir, "initial");
1809
1810 let oid = "1234567890abcdef1234567890abcdef12345678";
1811 let diff = format!(
1812 "diff --git a/vendor/lib b/vendor/lib\nnew file mode 160000\nindex 0000000..{oid}\n--- \
1813 /dev/null\n+++ b/vendor/lib\n@@ -0,0 +1 @@\n+Subproject commit {oid}\n"
1814 );
1815 let stat = " vendor/lib | 1 +\n";
1816 let snapshot = build_compose_snapshot(&diff, stat).unwrap();
1817 let gitlink_file = snapshot.file_by_path("vendor/lib").unwrap();
1818 let group = ComposeExecutableGroup {
1819 group_id: "G1".to_string(),
1820 commit_type: CommitType::new("chore").unwrap(),
1821 scope: None,
1822 file_ids: vec![gitlink_file.file_id.clone()],
1823 rationale: "add submodule".to_string(),
1824 dependencies: vec![],
1825 hunk_ids: gitlink_file.hunk_ids.clone(),
1826 };
1827
1828 reset_staging(dir.path().to_str().unwrap()).unwrap();
1829 let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1830
1831 assert_eq!(result.result, StageResult::Staged);
1832 let staged = staged_diff(&dir);
1833 assert!(staged.contains("new file mode 160000"));
1834 assert!(staged.contains(&format!("+Subproject commit {oid}")));
1835 }
1836
1837 #[test]
1838 fn test_stage_executable_group_skips_file_whose_patch_no_longer_applies() {
1839 let dir = init_repo();
1840 write_file(&dir, "src/a.rs", &fixture_file_original());
1841 write_file(&dir, "src/b.rs", "fn b() {}\n");
1842 commit_all(&dir, "initial");
1843
1844 write_file(&dir, "src/a.rs", &fixture_file_two_hunks());
1845 write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
1846
1847 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1848 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1849 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1850 let a_file = snapshot.file_by_path("src/a.rs").unwrap();
1851 let b_file = snapshot.file_by_path("src/b.rs").unwrap();
1852 let group = ComposeExecutableGroup {
1853 group_id: "G1".to_string(),
1854 commit_type: CommitType::new("refactor").unwrap(),
1855 scope: None,
1856 file_ids: vec![a_file.file_id.clone(), b_file.file_id.clone()],
1857 rationale: "both files".to_string(),
1858 dependencies: vec![],
1859 hunk_ids: std::iter::once(a_file.hunk_ids[0].clone())
1862 .chain(b_file.hunk_ids.iter().cloned())
1863 .collect(),
1864 };
1865
1866 write_file(&dir, "src/a.rs", &fixture_file_original().replace("alpha", "alpha diverged"));
1869 run_git(&dir, &["add", "src/a.rs"]);
1870 run_git(&dir, &["commit", "-m", "diverge a"]);
1871
1872 reset_staging(dir.path().to_str().unwrap()).unwrap();
1873 write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
1874
1875 let outcome =
1876 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1877
1878 assert_eq!(outcome.result, StageResult::Staged);
1880 assert_eq!(outcome.skipped.len(), 1);
1881 assert_eq!(outcome.skipped[0].path, "src/a.rs");
1882
1883 let staged = staged_diff(&dir);
1884 assert!(staged.contains("b_changed"));
1885 assert!(!staged.contains("alpha changed"));
1886 assert!(!staged.contains("src/a.rs"));
1888 }
1889
1890 #[test]
1891 fn test_covers_all_modified_file_routes_to_git_add() {
1892 let dir = init_repo();
1893 write_file(&dir, "src/lib.rs", &fixture_file_original());
1894 commit_all(&dir, "initial");
1895 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1896
1897 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1898 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1899 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1900 let file = snapshot.file_by_path("src/lib.rs").unwrap();
1901 let group = ComposeExecutableGroup {
1902 group_id: "G1".to_string(),
1903 commit_type: CommitType::new("refactor").unwrap(),
1904 scope: None,
1905 file_ids: vec![file.file_id.clone()],
1906 rationale: "all hunks".to_string(),
1907 dependencies: vec![],
1908 hunk_ids: file.hunk_ids.clone(),
1909 };
1910
1911 let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
1912 assert!(group_patch.apply_patches.is_empty());
1914 assert_eq!(group_patch.fallback_files, vec!["src/lib.rs".to_string()]);
1915 }
1916
1917 #[test]
1918 fn test_stage_executable_group_in_index_stages_crlf_file_via_git_add() {
1919 let dir = init_repo();
1920 run_git(&dir, &["config", "core.autocrlf", "false"]);
1921 let original = [
1922 "fn alpha() {",
1923 " println!(\"alpha\");",
1924 "}",
1925 "",
1926 "// spacer 1",
1927 "// spacer 2",
1928 "// spacer 3",
1929 "// spacer 4",
1930 "fn beta() {",
1931 " println!(\"beta\");",
1932 "}",
1933 "",
1934 ]
1935 .join("\r\n");
1936 let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
1937 write_file(&dir, "src/crlf.rs", &original);
1938 commit_all(&dir, "initial");
1939 write_file(&dir, "src/crlf.rs", &modified);
1940
1941 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1942 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1943 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1944 let file = snapshot.file_by_path("src/crlf.rs").unwrap();
1945 let group = ComposeExecutableGroup {
1946 group_id: "G1".to_string(),
1947 commit_type: CommitType::new("fix").unwrap(),
1948 scope: None,
1949 file_ids: vec![file.file_id.clone()],
1950 rationale: "crlf change".to_string(),
1951 dependencies: vec![],
1952 hunk_ids: file.hunk_ids.clone(),
1953 };
1954
1955 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
1956 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
1957 let outcome = stage_executable_group_in_index(
1958 &snapshot,
1959 &group,
1960 dir.path().to_str().unwrap(),
1961 index.path(),
1962 )
1963 .unwrap();
1964 assert!(outcome.skipped.is_empty());
1965
1966 let staged = crate::git::git_command_with_index(index.path())
1967 .args(["show", ":src/crlf.rs"])
1968 .current_dir(dir.path())
1969 .output()
1970 .unwrap();
1971 assert!(staged.status.success());
1972 assert_eq!(String::from_utf8_lossy(&staged.stdout), modified);
1974 }
1975
1976 #[test]
1977 fn test_force_stage_splice_partial_crlf_preserves_eol() {
1978 let dir = init_repo();
1979 run_git(&dir, &["config", "core.autocrlf", "false"]);
1980 let original = [
1981 "fn alpha() {",
1982 " println!(\"alpha\");",
1983 "}",
1984 "",
1985 "// spacer 1",
1986 "// spacer 2",
1987 "// spacer 3",
1988 "// spacer 4",
1989 "// spacer 5",
1990 "// spacer 6",
1991 "fn beta() {",
1992 " println!(\"beta\");",
1993 "}",
1994 "",
1995 ]
1996 .join("\r\n");
1997 let modified = original
1999 .replace("println!(\"alpha\")", "println!(\"alpha changed\")")
2000 .replace("println!(\"beta\")", "println!(\"beta changed\")");
2001 write_file(&dir, "src/crlf.rs", &original);
2002 commit_all(&dir, "initial");
2003 write_file(&dir, "src/crlf.rs", &modified);
2004
2005 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2006 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2007 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2008 let file = snapshot.file_by_path("src/crlf.rs").unwrap();
2009 assert!(file.hunk_ids.len() >= 2, "need at least two hunks for a partial test");
2010
2011 let first_hunk = vec![file.hunk_ids[0].clone()];
2013 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2014 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2015 force_stage_file_from_base_in_index(
2016 &snapshot,
2017 &file.file_id,
2018 &first_hunk,
2019 dir.path().to_str().unwrap(),
2020 index.path(),
2021 )
2022 .unwrap();
2023
2024 let staged = crate::git::git_command_with_index(index.path())
2025 .args(["show", ":src/crlf.rs"])
2026 .current_dir(dir.path())
2027 .output()
2028 .unwrap();
2029 let staged = String::from_utf8_lossy(&staged.stdout).to_string();
2030 let expected = original.replace("println!(\"alpha\")", "println!(\"alpha changed\")");
2031 assert_eq!(staged, expected);
2032 assert!(staged.contains("println!(\"alpha changed\");\r\n"));
2034 assert!(!staged.contains("beta changed"));
2035 assert!(staged.contains("println!(\"beta\");\r\n"));
2036 assert!(!staged.contains("\r\r"));
2037 }
2038
2039 #[test]
2040 fn test_splice_hunks_unit_lf_and_crlf() {
2041 use crate::compose_types::ComposeHunk;
2043 fn hunk(old_start: usize, raw: &str) -> ComposeHunk {
2044 ComposeHunk {
2045 hunk_id: "H".to_string(),
2046 file_id: "F".to_string(),
2047 path: "f".to_string(),
2048 old_start,
2049 old_count: 0,
2050 new_start: 0,
2051 new_count: 0,
2052 header: String::new(),
2053 raw_patch: raw.to_string(),
2054 snippet: String::new(),
2055 semantic_key: String::new(),
2056 synthetic: false,
2057 }
2058 }
2059 let base = b"a\nb\nc\n";
2061 let h = hunk(1, "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n");
2062 assert_eq!(splice_hunks_into_base(base, &[&h]), b"a\nB\nc\n");
2063
2064 let base_cr = b"a\r\nb\r\nc\r\n";
2066 let h_cr = hunk(1, "@@ -1,3 +1,3 @@\n a\r\n-b\r\n+B\r\n c\r\n");
2067 assert_eq!(splice_hunks_into_base(base_cr, &[&h_cr]), b"a\r\nB\r\nc\r\n");
2068
2069 let base2 = b"a\nb\nc\n";
2071 let h2 = hunk(3, "@@ -3 +3 @@\n-c\n+c2\n\\ No newline at end of file\n");
2072 assert_eq!(splice_hunks_into_base(base2, &[&h2]), b"a\nb\nc2");
2073 }
2074
2075 #[test]
2076 fn test_stage_executable_group_in_index_preserves_real_staged_diff() {
2077 let dir = init_repo();
2078 write_file(&dir, "src/lib.rs", &fixture_file_original());
2079 write_file(&dir, "sentinel.txt", "base\n");
2080 commit_all(&dir, "initial");
2081 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
2082
2083 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2084 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2085 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2086 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
2087 let group = ComposeExecutableGroup {
2088 group_id: "G1".to_string(),
2089 commit_type: CommitType::new("refactor").unwrap(),
2090 scope: None,
2091 file_ids: vec![source_file.file_id.clone()],
2092 rationale: "source change".to_string(),
2093 dependencies: vec![],
2094 hunk_ids: source_file.hunk_ids.clone(),
2095 };
2096
2097 write_file(&dir, "sentinel.txt", "base\nstaged sentinel\n");
2098 run_git(&dir, &["add", "sentinel.txt"]);
2099 let real_staged_before = staged_diff(&dir);
2100 assert!(real_staged_before.contains("staged sentinel"));
2101
2102 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2103 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2104 let outcome = stage_executable_group_in_index(
2105 &snapshot,
2106 &group,
2107 dir.path().to_str().unwrap(),
2108 index.path(),
2109 )
2110 .unwrap();
2111
2112 assert_eq!(outcome.result, StageResult::Staged);
2113 assert_eq!(staged_diff(&dir), real_staged_before);
2114 let temp_staged = staged_diff_in_index(&dir, &index);
2115 assert!(temp_staged.contains("alpha staged"));
2116 assert!(!temp_staged.contains("staged sentinel"));
2117 }
2118
2119 #[test]
2120 fn test_force_stage_file_from_base_in_index_preserves_real_staged_diff() {
2121 let dir = init_repo();
2122 run_git(&dir, &["config", "core.autocrlf", "false"]);
2123 let original = [
2124 "fn alpha() {",
2125 " println!(\"alpha\");",
2126 "}",
2127 "",
2128 "fn beta() {",
2129 " println!(\"beta\");",
2130 "}",
2131 "",
2132 ]
2133 .join("\r\n");
2134 let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2135 write_file(&dir, "src/crlf.rs", &original);
2136 write_file(&dir, "sentinel.txt", "base\n");
2137 commit_all(&dir, "initial");
2138 write_file(&dir, "src/crlf.rs", &modified);
2139
2140 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2141 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2142 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2143 let source_file = snapshot.file_by_path("src/crlf.rs").unwrap();
2144
2145 write_file(&dir, "sentinel.txt", "base\nstaged sentinel\n");
2146 run_git(&dir, &["add", "sentinel.txt"]);
2147 let real_staged_before = staged_diff(&dir);
2148
2149 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2150 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2151 force_stage_file_from_base_in_index(
2152 &snapshot,
2153 &source_file.file_id,
2154 &source_file.hunk_ids.clone(),
2155 dir.path().to_str().unwrap(),
2156 index.path(),
2157 )
2158 .unwrap();
2159
2160 assert_eq!(staged_diff(&dir), real_staged_before);
2161 let staged_blob = crate::git::git_command_with_index(index.path())
2162 .args(["show", ":src/crlf.rs"])
2163 .current_dir(dir.path())
2164 .output()
2165 .unwrap();
2166 assert!(staged_blob.status.success());
2167 assert_eq!(String::from_utf8_lossy(&staged_blob.stdout).to_string(), modified);
2168 }
2169
2170 #[test]
2171 fn test_force_stage_file_from_base_preserves_crlf_patch_lines() {
2172 let dir = init_repo();
2173 run_git(&dir, &["config", "core.autocrlf", "false"]);
2174 let original = [
2175 "fn alpha() {",
2176 " println!(\"alpha\");",
2177 "}",
2178 "",
2179 "fn beta() {",
2180 " println!(\"beta\");",
2181 "}",
2182 "",
2183 ]
2184 .join("\r\n");
2185 let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2186 write_file(&dir, "src/crlf.rs", &original);
2187 commit_all(&dir, "initial");
2188 write_file(&dir, "src/crlf.rs", &modified);
2189
2190 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2191 assert!(diff.contains("- println!(\"beta\");\r\n"));
2192 assert!(diff.contains("+ println!(\"beta changed\");\r\n"));
2193 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2194 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2195 let source_file = snapshot.file_by_path("src/crlf.rs").unwrap();
2196
2197 reset_staging(dir.path().to_str().unwrap()).unwrap();
2198 force_stage_file_from_base(
2199 &snapshot,
2200 &source_file.file_id,
2201 &source_file.hunk_ids.clone(),
2202 dir.path().to_str().unwrap(),
2203 )
2204 .unwrap();
2205
2206 let staged_blob = run_git(&dir, &["show", ":src/crlf.rs"]);
2207 assert_eq!(staged_blob, modified);
2208 }
2209 #[test]
2210 fn test_force_stage_file_from_base_ignores_index_drift() {
2211 let dir = init_repo();
2212 write_file(&dir, "src/lib.rs", &fixture_file_original());
2213 commit_all(&dir, "initial");
2214 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2215
2216 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2217 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2218 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2219 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
2220 assert_eq!(source_file.hunk_ids.len(), 2);
2221
2222 write_file(&dir, "src/lib.rs", "fn totally_different() {}\n");
2225 run_git(&dir, &["add", "src/lib.rs"]);
2226
2227 force_stage_file_from_base(
2229 &snapshot,
2230 &source_file.file_id,
2231 &[source_file.hunk_ids[0].clone()],
2232 dir.path().to_str().unwrap(),
2233 )
2234 .unwrap();
2235
2236 let staged = staged_diff(&dir);
2237 assert!(staged.contains("alpha changed"));
2238 assert!(!staged.contains("beta changed"));
2239 assert!(!staged.contains("totally_different"));
2240
2241 force_stage_file_from_base(
2243 &snapshot,
2244 &source_file.file_id,
2245 &source_file.hunk_ids.clone(),
2246 dir.path().to_str().unwrap(),
2247 )
2248 .unwrap();
2249 let staged = staged_diff(&dir);
2250 assert!(staged.contains("alpha changed"));
2251 assert!(staged.contains("beta changed"));
2252 assert!(!staged.contains("totally_different"));
2253 }
2254
2255 #[test]
2256 fn test_force_stage_split_across_commits_leaves_worktree_clean() {
2257 let dir = init_repo();
2258 write_file(&dir, "src/lib.rs", &fixture_file_original());
2259 commit_all(&dir, "initial");
2260 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2262
2263 let dirs = dir.path().to_str().unwrap();
2264 let diff = get_compose_diff(dirs).unwrap();
2265 let stat = get_compose_stat(dirs).unwrap();
2266 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2267 let file = snapshot.file_by_path("src/lib.rs").unwrap();
2268 assert_eq!(file.hunk_ids.len(), 2);
2269
2270 reset_staging(dirs).unwrap();
2271
2272 force_stage_file_from_base(&snapshot, &file.file_id, &[file.hunk_ids[0].clone()], dirs)
2274 .unwrap();
2275 run_git(&dir, &["commit", "-m", "first"]);
2276
2277 force_stage_file_from_base(&snapshot, &file.file_id, &file.hunk_ids.clone(), dirs).unwrap();
2279 run_git(&dir, &["commit", "-m", "second"]);
2280
2281 let status = run_git(&dir, &["status", "--porcelain"]);
2284 assert!(status.trim().is_empty(), "working tree should be clean, got: {status:?}");
2285 }
2286}