Skip to main content

llm_git/
patch.rs

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/// Outcome of attempting to apply a single file's patch to the index.
80#[derive(Debug, Clone, PartialEq, Eq)]
81enum FilePatchOutcome {
82   Staged,
83   AlreadyApplied,
84   Empty,
85   Failed(String),
86}
87
88/// A planned file whose patch could not be applied against the current state.
89///
90/// Its changes are intentionally left untouched in the working tree.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct SkippedFile {
93   pub path:   String,
94   pub reason: String,
95}
96
97/// Result of staging a compose group, including any files whose planned patch
98/// no longer applies and were therefore left uncommitted.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ComposeStageOutcome {
101   pub result:  StageResult,
102   pub skipped: Vec<SkippedFile>,
103}
104
105/// Run `git apply` with a patch supplied on stdin.
106fn 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
156/// Apply a single file's patch to the staging area.
157///
158/// A patch that no longer applies against the current index/worktree is
159/// reported as [`FilePatchOutcome::Failed`] instead of erroring, so callers can
160/// stage the files that do apply and leave the rest untouched in the worktree.
161fn 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
183/// Restore a single path's index entry to HEAD, discarding any partial or
184/// conflicted staging left behind by a failed `git apply` (a 3-way apply leaves
185/// unmerged index entries on conflict). The working-tree copy, holding the
186/// user's divergent changes, is deliberately left untouched.
187fn 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
203/// Resolve a (possibly abbreviated) blob id from a diff header to its full oid.
204fn 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
222/// Read a blob's raw bytes by object id.
223fn 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
238/// Resolve a file's base (pre-change) blob bytes and index mode from its diff
239/// header. New files (all-zero base oid, or no usable `index` line) resolve to
240/// empty bytes so the splice can build their contents from scratch.
241fn 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
278/// Split bytes into lines, each retaining its terminator (`\r\n`, `\n`, or none
279/// at EOF).
280fn 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
295/// The file's dominant line ending, used for added lines (whose EOL the diff
296/// text does not reliably carry).
297fn 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
314/// Drop a trailing `\n` (and a preceding `\r`) from the buffer's last line.
315fn 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
324/// Reconstruct a file's content from its base blob plus the selected hunks,
325/// without `git apply`. Context and deleted lines are taken verbatim from the
326/// base (so exact byte content and line endings survive even when the diff text
327/// normalizes them); added lines use the file's dominant EOL. Hunks are applied
328/// in base-coordinate order, so a subset of a file's hunks splices correctly.
329fn 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; // 0-based index into base_lines
338
339   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            // hunk header (`@@ ... @@`)
350            continue;
351         }
352         let bytes = line.as_bytes();
353         if bytes.first() == Some(&b'\\') {
354            // "\ No newline at end of file": only meaningful when it follows an
355            // output-producing line (added/context); after a deletion it refers
356            // to the old side and must not alter the output.
357            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               // context line (leading space) or stray line: copy from base
378               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/// Force a file's index entry to `base + the selected hunks`, ignoring the
397/// current index/worktree state entirely.
398///
399/// The entry is pinned to the snapshot's base blob (the file's original HEAD
400/// content) and the selected hunks are applied against that base. Because every
401/// hunk is anchored in the base it was generated from, this applies cleanly
402/// where a state-sensitive `git apply` against the live index would conflict.
403/// The working tree is never touched: only the index is rewritten.
404#[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   // Clear any residue, then rewrite the index entry to the deterministically
459   // spliced target blob. No `git apply`: context/deleted lines come straight
460   // from the base blob, so line endings and exact bytes are preserved.
461   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/// Stage specific files.
471#[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/// Reset staging area.
599#[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         // New files (and submodule gitlinks) keep their existing handling:
1158         // covers-all builds the blob from the diff; partial falls back to apply.
1159         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         // Whole-file change: stage straight from the working tree. No patch is
1166         // reconstructed or applied, so line-ending/whitespace normalization can
1167         // never make git reject its own diff.
1168         fallback_files.push(file.path.clone());
1169      } else {
1170         // Partial change to a shared file: apply just these hunks; if the apply
1171         // is refused, the caller re-stages from base via splice.
1172         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            // The planned patch no longer applies against the current state.
1221            // Drop any conflicted index residue and keep the worktree change.
1222            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      // Re-staging the same whole-file change is idempotent: `git add` restages
1677      // identical worktree content, so the index still matches HEAD afterward.
1678      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         // Select only a's first hunk (partial) so it routes through git apply
1860         // (covers-all files are staged via git add and never "skip").
1861         hunk_ids:     std::iter::once(a_file.hunk_ids[0].clone())
1862            .chain(b_file.hunk_ids.iter().cloned())
1863            .collect(),
1864      };
1865
1866      // Diverge src/a.rs at the same lines the plan touches and commit it, so the
1867      // planned hunks for that file no longer apply (3-way merge conflicts).
1868      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      // src/b.rs still applies, so the group is committable; src/a.rs is skipped.
1879      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      // The skipped file's index entry is restored to HEAD: no conflict residue.
1887      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      // Whole-file change must be staged via git add, never via git apply.
1913      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      // CRLF preserved exactly, identical to the working tree.
1973      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      // Change both alpha and beta so there are two separate hunks.
1998      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      // Force-stage only the FIRST hunk (alpha) -> base + alpha hunk, CRLF kept.
2012      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      // Added line carries the file's CRLF (not the diff's normalization).
2033      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      // Direct unit test of the splicer against synthetic hunks.
2042      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      // LF base, change middle line.
2060      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      // CRLF base, change middle line; added line must get CRLF, no double CR.
2065      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      // No trailing newline at EOF on the new side.
2070      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      // Drift the index far from base: stage an unrelated full-file rewrite, so a
2223      // normal `git apply` of the planned hunks against this index would fail.
2224      write_file(&dir, "src/lib.rs", "fn totally_different() {}\n");
2225      run_git(&dir, &["add", "src/lib.rs"]);
2226
2227      // Force-stage only the first planned hunk from base, ignoring the drift.
2228      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      // Applying both hunks reconstructs the full planned target from base.
2242      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      // The working tree holds the full planned change and is never rewritten.
2261      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      // Commit 1 takes the first hunk (cumulative = [h0]).
2273      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      // Commit 2 takes both hunks (cumulative = [h0, h1]).
2278      force_stage_file_from_base(&snapshot, &file.file_id, &file.hunk_ids.clone(), dirs).unwrap();
2279      run_git(&dir, &["commit", "-m", "second"]);
2280
2281      // The two commits together reproduce the working tree exactly: nothing is
2282      // left uncommitted on disk and no file was modified by staging.
2283      let status = run_git(&dir, &["status", "--porcelain"]);
2284      assert!(status.trim().is_empty(), "working tree should be clean, got: {status:?}");
2285   }
2286}