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::{
9      ComposeExecutableGroup, ComposeFile, ComposeHunk, ComposeSnapshot, WorktreePin,
10   },
11   error::{CommitGenError, Result},
12   git::{git_command, git_command_with_index},
13};
14
15#[derive(Debug, Clone)]
16struct ParsedHunk {
17   old_start: usize,
18   old_count: usize,
19   new_start: usize,
20   new_count: usize,
21   header:    String,
22   lines:     Vec<String>,
23}
24
25#[derive(Debug, Clone)]
26struct ParsedFile {
27   path:         String,
28   header_lines: Vec<String>,
29   hunks:        Vec<ParsedHunk>,
30   additions:    usize,
31   deletions:    usize,
32   is_binary:    bool,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ComposeGroupPatch {
37   pub diff:       String,
38   pub stat:       String,
39   apply_patches:  Vec<FilePatch>,
40   fallback_files: Vec<String>,
41   index_blobs:    Vec<IndexBlob>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45struct FilePatch {
46   path:  String,
47   patch: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51struct IndexBlob {
52   path:   String,
53   mode:   String,
54   object: IndexObject,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58enum IndexObject {
59   BlobContents(String),
60   BlobBytes(Vec<u8>),
61   ExistingObject(String),
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StageResult {
66   Staged,
67   AlreadyApplied,
68   EmptyPatch,
69}
70
71impl StageResult {
72   const fn combine(self, other: Self) -> Self {
73      match (self, other) {
74         (Self::Staged, _) | (_, Self::Staged) => Self::Staged,
75         (Self::AlreadyApplied, _) | (_, Self::AlreadyApplied) => Self::AlreadyApplied,
76         (Self::EmptyPatch, Self::EmptyPatch) => Self::EmptyPatch,
77      }
78   }
79}
80
81/// Outcome of attempting to apply a single file's patch to the index.
82#[derive(Debug, Clone, PartialEq, Eq)]
83enum FilePatchOutcome {
84   Staged,
85   AlreadyApplied,
86   Empty,
87   Failed(String),
88}
89
90/// A planned file whose patch could not be applied against the current state.
91///
92/// Its changes are intentionally left untouched in the working tree.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct SkippedFile {
95   pub path:   String,
96   pub reason: String,
97}
98
99/// Result of staging a compose group, including any files whose planned patch
100/// no longer applies and were therefore left uncommitted.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ComposeStageOutcome {
103   pub result:  StageResult,
104   pub skipped: Vec<SkippedFile>,
105}
106
107/// Run `git apply` with a patch supplied on stdin.
108fn git_command_for_index(index_file: Option<&Path>) -> std::process::Command {
109   if let Some(index_file) = index_file {
110      git_command_with_index(index_file)
111   } else {
112      git_command()
113   }
114}
115
116fn run_git_apply(
117   patch: &str,
118   args: &[&str],
119   dir: &str,
120   index_file: Option<&Path>,
121) -> Result<std::process::Output> {
122   let mut child = git_command_for_index(index_file)
123      .args(args)
124      .current_dir(dir)
125      .stdin(std::process::Stdio::piped())
126      .stdout(std::process::Stdio::piped())
127      .stderr(std::process::Stdio::piped())
128      .spawn()
129      .map_err(|e| CommitGenError::git(format!("Failed to spawn git apply: {e}")))?;
130
131   if let Some(mut stdin) = child.stdin.take() {
132      use std::io::Write;
133
134      stdin
135         .write_all(patch.as_bytes())
136         .map_err(|e| CommitGenError::git(format!("Failed to write patch: {e}")))?;
137   }
138
139   child
140      .wait_with_output()
141      .map_err(|e| CommitGenError::git(format!("Failed to wait for git apply: {e}")))
142}
143
144fn patch_is_already_applied_to_index(
145   patch: &str,
146   dir: &str,
147   index_file: Option<&Path>,
148) -> Result<bool> {
149   let output = run_git_apply(
150      patch,
151      &["apply", "--cached", "--reverse", "--check", "--recount"],
152      dir,
153      index_file,
154   )?;
155   Ok(output.status.success())
156}
157
158/// Apply a single file's patch to the staging area.
159///
160/// A patch that no longer applies against the current index/worktree is
161/// reported as [`FilePatchOutcome::Failed`] instead of erroring, so callers can
162/// stage the files that do apply and leave the rest untouched in the worktree.
163fn apply_file_patch_to_index(
164   patch: &str,
165   dir: &str,
166   index_file: Option<&Path>,
167) -> Result<FilePatchOutcome> {
168   if patch.trim().is_empty() {
169      return Ok(FilePatchOutcome::Empty);
170   }
171
172   if patch_is_already_applied_to_index(patch, dir, index_file)? {
173      return Ok(FilePatchOutcome::AlreadyApplied);
174   }
175
176   let output =
177      run_git_apply(patch, &["apply", "--cached", "--3way", "--recount"], dir, index_file)?;
178   if output.status.success() {
179      return Ok(FilePatchOutcome::Staged);
180   }
181
182   Ok(FilePatchOutcome::Failed(String::from_utf8_lossy(&output.stderr).trim().to_string()))
183}
184
185/// Restore a single path's index entry to HEAD, discarding any partial or
186/// conflicted staging left behind by a failed `git apply` (a 3-way apply leaves
187/// unmerged index entries on conflict). The working-tree copy, holding the
188/// user's divergent changes, is deliberately left untouched.
189fn restore_index_path_to_head(path: &str, dir: &str, index_file: Option<&Path>) -> Result<()> {
190   let output = git_command_for_index(index_file)
191      .args(["reset", "-q", "HEAD", "--"])
192      .arg(path)
193      .current_dir(dir)
194      .output()
195      .map_err(|e| CommitGenError::git(format!("Failed to reset index entry {path}: {e}")))?;
196
197   if !output.status.success() {
198      let stderr = String::from_utf8_lossy(&output.stderr);
199      return Err(CommitGenError::git(format!("git reset failed for {path}: {stderr}")));
200   }
201
202   Ok(())
203}
204
205/// Resolve a (possibly abbreviated) blob id from a diff header to its full oid.
206fn resolve_blob_oid(oid: &str, path: &str, dir: &str) -> Result<String> {
207   let output = git_command()
208      .args(["rev-parse", "--verify", "--quiet"])
209      .arg(format!("{oid}^{{blob}}"))
210      .current_dir(dir)
211      .output()
212      .map_err(|e| CommitGenError::git(format!("Failed to resolve base blob for {path}: {e}")))?;
213
214   let full = String::from_utf8_lossy(&output.stdout).trim().to_string();
215   if !output.status.success() || full.is_empty() {
216      return Err(CommitGenError::git(format!(
217         "Cannot resolve base blob {oid} for {path}: object not found"
218      )));
219   }
220
221   Ok(full)
222}
223
224/// Read a blob's raw bytes by object id.
225fn cat_file_blob(oid: &str, path: &str, dir: &str) -> Result<Vec<u8>> {
226   let output = git_command()
227      .args(["cat-file", "blob", oid])
228      .current_dir(dir)
229      .output()
230      .map_err(|e| CommitGenError::git(format!("Failed to read base blob for {path}: {e}")))?;
231
232   if !output.status.success() {
233      let stderr = String::from_utf8_lossy(&output.stderr);
234      return Err(CommitGenError::git(format!("git cat-file blob failed for {path}: {stderr}")));
235   }
236
237   Ok(output.stdout)
238}
239
240/// Resolve a file's base (pre-change) blob bytes and index mode from its diff
241/// header. New files (all-zero base oid, or no usable `index` line) resolve to
242/// empty bytes so the splice can build their contents from scratch.
243fn resolve_base_blob(file: &ComposeFile, dir: &str) -> Result<(Vec<u8>, String)> {
244   let index_line = file
245      .patch_header
246      .lines()
247      .find(|line| line.starts_with("index "));
248
249   let base_oid = index_line.and_then(|line| {
250      let rest = line.strip_prefix("index ")?;
251      let range = rest.split_whitespace().next()?;
252      range.split_once("..").map(|(base, _)| base)
253   });
254
255   match base_oid {
256      Some(oid) if !oid.is_empty() && oid.bytes().any(|byte| byte != b'0') => {
257         let full = resolve_blob_oid(oid, &file.path, dir)?;
258         let bytes = cat_file_blob(&full, &file.path, dir)?;
259         let mode = index_line
260            .and_then(|line| line.strip_prefix("index "))
261            .and_then(|rest| rest.split_whitespace().nth(1))
262            .map(str::to_string)
263            .or_else(|| {
264               file.patch_header.lines().find_map(|line| {
265                  line
266                     .strip_prefix("old mode ")
267                     .map(|mode| mode.trim().to_string())
268               })
269            })
270            .unwrap_or_else(|| "100644".to_string());
271         Ok((bytes, mode))
272      },
273      _ => {
274         let mode = new_file_mode(file).unwrap_or("100644").to_string();
275         Ok((Vec::new(), mode))
276      },
277   }
278}
279
280/// Split bytes into lines, each retaining its terminator (`\r\n`, `\n`, or none
281/// at EOF).
282fn split_lines_keep_eol(data: &[u8]) -> Vec<&[u8]> {
283   let mut lines = Vec::new();
284   let mut start = 0usize;
285   while start < data.len() {
286      if let Some(rel) = data[start..].iter().position(|&byte| byte == b'\n') {
287         lines.push(&data[start..=start + rel]);
288         start += rel + 1;
289      } else {
290         lines.push(&data[start..]);
291         break;
292      }
293   }
294   lines
295}
296
297/// The file's dominant line ending, used for added lines (whose EOL the diff
298/// text does not reliably carry).
299fn dominant_eol(lines: &[&[u8]]) -> &'static [u8] {
300   let mut crlf = 0usize;
301   let mut lf = 0usize;
302   for line in lines {
303      if line.ends_with(b"\r\n") {
304         crlf += 1;
305      } else if line.ends_with(b"\n") {
306         lf += 1;
307      }
308   }
309   if crlf > 0 && crlf >= lf {
310      b"\r\n"
311   } else {
312      b"\n"
313   }
314}
315
316/// Drop a trailing `\n` (and a preceding `\r`) from the buffer's last line.
317fn strip_trailing_eol(buf: &mut Vec<u8>) {
318   if buf.last() == Some(&b'\n') {
319      buf.pop();
320      if buf.last() == Some(&b'\r') {
321         buf.pop();
322      }
323   }
324}
325
326/// Reconstruct a file's content from its base blob plus the selected hunks,
327/// without `git apply`. Context and deleted lines are taken verbatim from the
328/// base (so exact byte content and line endings survive even when the diff text
329/// normalizes them); added lines use the file's dominant EOL. Hunks are applied
330/// in base-coordinate order, so a subset of a file's hunks splices correctly.
331fn splice_hunks_into_base(base: &[u8], hunks: &[&ComposeHunk]) -> Vec<u8> {
332   let base_lines = split_lines_keep_eol(base);
333   let eol = dominant_eol(&base_lines);
334
335   let mut ordered: Vec<&&ComposeHunk> = hunks.iter().collect();
336   ordered.sort_by_key(|hunk| hunk.old_start);
337
338   let mut out: Vec<u8> = Vec::with_capacity(base.len());
339   let mut cursor = 0usize; // 0-based index into base_lines
340
341   for hunk in ordered {
342      let start = hunk.old_start.saturating_sub(1);
343      while cursor < start && cursor < base_lines.len() {
344         out.extend_from_slice(base_lines[cursor]);
345         cursor += 1;
346      }
347
348      let mut prev: u8 = 0;
349      for (idx, line) in diff_lines_preserve_cr(&hunk.raw_patch).enumerate() {
350         if idx == 0 {
351            // hunk header (`@@ ... @@`)
352            continue;
353         }
354         let bytes = line.as_bytes();
355         if bytes.first() == Some(&b'\\') {
356            // "\ No newline at end of file": only meaningful when it follows an
357            // output-producing line (added/context); after a deletion it refers
358            // to the old side and must not alter the output.
359            if prev == b'+' || prev == b' ' {
360               strip_trailing_eol(&mut out);
361            }
362            continue;
363         }
364         match bytes.first() {
365            Some(b'-') => {
366               cursor += 1;
367               prev = b'-';
368            },
369            Some(b'+') => {
370               let mut content = &bytes[1..];
371               if content.last() == Some(&b'\r') {
372                  content = &content[..content.len() - 1];
373               }
374               out.extend_from_slice(content);
375               out.extend_from_slice(eol);
376               prev = b'+';
377            },
378            _ => {
379               // context line (leading space) or stray line: copy from base
380               if cursor < base_lines.len() {
381                  out.extend_from_slice(base_lines[cursor]);
382                  cursor += 1;
383               }
384               prev = b' ';
385            },
386         }
387      }
388   }
389
390   while cursor < base_lines.len() {
391      out.extend_from_slice(base_lines[cursor]);
392      cursor += 1;
393   }
394
395   out
396}
397
398/// Force a file's index entry to `base + the selected hunks`, ignoring the
399/// current index/worktree state entirely.
400///
401/// The entry is pinned to the snapshot's base blob (the file's original HEAD
402/// content) and the selected hunks are applied against that base. Because every
403/// hunk is anchored in the base it was generated from, this applies cleanly
404/// where a state-sensitive `git apply` against the live index would conflict.
405/// The working tree is never touched: only the index is rewritten.
406#[tracing::instrument(target = "lgit", name = "patch.force_stage_file_from_base", skip_all, fields(dir, file_id, hunk_count = selected_hunk_ids.len()))]
407pub fn force_stage_file_from_base(
408   snapshot: &ComposeSnapshot,
409   file_id: &str,
410   selected_hunk_ids: &[String],
411   dir: &str,
412) -> Result<()> {
413   force_stage_file_from_base_with_index(snapshot, file_id, selected_hunk_ids, dir, None)
414}
415
416#[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()))]
417pub fn force_stage_file_from_base_in_index(
418   snapshot: &ComposeSnapshot,
419   file_id: &str,
420   selected_hunk_ids: &[String],
421   dir: &str,
422   index_file: &Path,
423) -> Result<()> {
424   force_stage_file_from_base_with_index(
425      snapshot,
426      file_id,
427      selected_hunk_ids,
428      dir,
429      Some(index_file),
430   )
431}
432
433fn force_stage_file_from_base_with_index(
434   snapshot: &ComposeSnapshot,
435   file_id: &str,
436   selected_hunk_ids: &[String],
437   dir: &str,
438   index_file: Option<&Path>,
439) -> Result<()> {
440   let file = snapshot
441      .file_by_id(file_id)
442      .ok_or_else(|| CommitGenError::Other(format!("Unknown compose file id {file_id}")))?;
443
444   let ordered: Vec<&ComposeHunk> = file
445      .hunk_ids
446      .iter()
447      .filter(|hunk_id| {
448         selected_hunk_ids
449            .iter()
450            .any(|selected| selected == *hunk_id)
451      })
452      .filter_map(|hunk_id| snapshot.hunk_by_id(hunk_id))
453      .filter(|hunk| !hunk.raw_patch.is_empty())
454      .collect();
455
456   if ordered.is_empty() {
457      return Ok(());
458   }
459
460   // Clear any residue, then rewrite the index entry to the deterministically
461   // spliced target blob. No `git apply`: context/deleted lines come straight
462   // from the base blob, so line endings and exact bytes are preserved.
463   restore_index_path_to_head(&file.path, dir, index_file)?;
464   let (base_bytes, mode) = resolve_base_blob(file, dir)?;
465   let target = splice_hunks_into_base(&base_bytes, &ordered);
466   let blob = IndexBlob { path: file.path.clone(), mode, object: IndexObject::BlobBytes(target) };
467   stage_index_blob(&blob, dir, index_file)?;
468
469   Ok(())
470}
471
472/// Pin each snapshot file's worktree state into the object database.
473///
474/// Records `(mode, oid)` per path as of this moment, so whole-file staging
475/// later reproduces exactly this content regardless of subsequent worktree
476/// edits. Paths absent from the worktree are pinned as deletions.
477#[tracing::instrument(target = "lgit", name = "patch.pin_worktree_state", skip_all, fields(dir, file_count = snapshot.files.len()))]
478pub fn pin_snapshot_worktree_state(snapshot: &mut ComposeSnapshot, dir: &str) -> Result<()> {
479   let mut regular_paths: Vec<String> = Vec::new();
480
481   for file in &snapshot.files {
482      let full_path = Path::new(dir).join(&file.path);
483      let metadata = match std::fs::symlink_metadata(&full_path) {
484         Ok(metadata) => metadata,
485         Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
486            snapshot
487               .pins
488               .insert(file.path.clone(), WorktreePin::Deleted);
489            continue;
490         },
491         Err(err) => {
492            return Err(CommitGenError::git(format!(
493               "Failed to inspect worktree path {}: {err}",
494               file.path
495            )));
496         },
497      };
498
499      let file_type = metadata.file_type();
500      if file_type.is_symlink() {
501         let target = std::fs::read_link(&full_path).map_err(|err| {
502            CommitGenError::git(format!("Failed to read symlink {}: {err}", file.path))
503         })?;
504         let oid = hash_blob_bytes(target.as_os_str().as_encoded_bytes(), &file.path, dir)?;
505         snapshot
506            .pins
507            .insert(file.path.clone(), WorktreePin::Object { mode: "120000".to_string(), oid });
508      } else if file_type.is_dir() {
509         // Submodule worktree: pin the gitlink at its current HEAD. A
510         // directory without a resolvable HEAD keeps the legacy `git add`
511         // staging path.
512         if let Some(oid) = submodule_head(&full_path) {
513            snapshot
514               .pins
515               .insert(file.path.clone(), WorktreePin::Object { mode: "160000".to_string(), oid });
516         }
517      } else if !file.path.contains('\n') {
518         regular_paths.push(file.path.clone());
519      }
520      // Paths containing a newline cannot go through `--stdin-paths` and keep
521      // the legacy staging path.
522   }
523
524   let oids = hash_worktree_paths(&regular_paths, dir)?;
525   for (path, oid) in regular_paths.iter().zip(oids) {
526      let mode = worktree_file_mode(&Path::new(dir).join(path));
527      snapshot
528         .pins
529         .insert(path.clone(), WorktreePin::Object { mode, oid });
530   }
531
532   Ok(())
533}
534
535/// Hash worktree files into the odb in one `git hash-object --stdin-paths`
536/// call, applying the same content filters `git add` would.
537fn hash_worktree_paths(paths: &[String], dir: &str) -> Result<Vec<String>> {
538   if paths.is_empty() {
539      return Ok(Vec::new());
540   }
541
542   let mut child = git_command()
543      .args(["hash-object", "-w", "--stdin-paths"])
544      .current_dir(dir)
545      .stdin(std::process::Stdio::piped())
546      .stdout(std::process::Stdio::piped())
547      .stderr(std::process::Stdio::piped())
548      .spawn()
549      .map_err(|e| CommitGenError::git(format!("Failed to spawn git hash-object: {e}")))?;
550
551   {
552      let Some(mut stdin) = child.stdin.take() else {
553         return Err(CommitGenError::git("Failed to open git hash-object stdin".to_string()));
554      };
555
556      use std::io::Write;
557
558      for path in paths {
559         stdin
560            .write_all(path.as_bytes())
561            .and_then(|()| stdin.write_all(b"\n"))
562            .map_err(|e| CommitGenError::git(format!("Failed to write path {path}: {e}")))?;
563      }
564   }
565
566   let output = child
567      .wait_with_output()
568      .map_err(|e| CommitGenError::git(format!("Failed to wait for git hash-object: {e}")))?;
569
570   if !output.status.success() {
571      let stderr = String::from_utf8_lossy(&output.stderr);
572      return Err(CommitGenError::git(format!("git hash-object --stdin-paths failed: {stderr}")));
573   }
574
575   let oids: Vec<String> = String::from_utf8_lossy(&output.stdout)
576      .lines()
577      .map(str::to_string)
578      .collect();
579   if oids.len() != paths.len() {
580      return Err(CommitGenError::git(format!(
581         "git hash-object returned {} oids for {} paths",
582         oids.len(),
583         paths.len()
584      )));
585   }
586
587   Ok(oids)
588}
589
590/// Index mode for a worktree file, mirroring `git add`: executable bit maps
591/// to 100755, everything else to 100644.
592fn worktree_file_mode(path: &Path) -> String {
593   #[cfg(unix)]
594   {
595      use std::os::unix::fs::PermissionsExt;
596      if let Ok(metadata) = std::fs::metadata(path)
597         && metadata.permissions().mode() & 0o111 != 0
598      {
599         return "100755".to_string();
600      }
601   }
602   #[cfg(not(unix))]
603   let _ = path;
604   "100644".to_string()
605}
606
607/// Current HEAD of a submodule checkout, if resolvable.
608fn submodule_head(path: &Path) -> Option<String> {
609   let output = git_command()
610      .args(["rev-parse", "HEAD"])
611      .current_dir(path)
612      .output()
613      .ok()?;
614   if !output.status.success() {
615      return None;
616   }
617   let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
618   (!oid.is_empty()).then_some(oid)
619}
620
621/// Stage a path's deletion, matching a [`WorktreePin::Deleted`] pin.
622fn remove_index_path(path: &str, dir: &str, index_file: Option<&Path>) -> Result<StageResult> {
623   let listed = git_command_for_index(index_file)
624      .args(["ls-files", "--"])
625      .arg(path)
626      .current_dir(dir)
627      .output()
628      .map_err(|e| CommitGenError::git(format!("Failed to inspect index entry {path}: {e}")))?;
629
630   if listed.status.success() && listed.stdout.is_empty() {
631      return Ok(StageResult::AlreadyApplied);
632   }
633
634   let output = git_command_for_index(index_file)
635      .args(["update-index", "--force-remove", "--"])
636      .arg(path)
637      .current_dir(dir)
638      .output()
639      .map_err(|e| CommitGenError::git(format!("Failed to remove index entry {path}: {e}")))?;
640
641   if !output.status.success() {
642      let stderr = String::from_utf8_lossy(&output.stderr);
643      return Err(CommitGenError::git(format!("git update-index failed for {path}: {stderr}")));
644   }
645
646   Ok(StageResult::Staged)
647}
648
649/// Stage specific files.
650#[tracing::instrument(target = "lgit", name = "patch.stage_files", skip_all, fields(dir, file_count = files.len()))]
651pub fn stage_files(files: &[String], dir: &str) -> Result<()> {
652   stage_files_with_index(files, dir, None)
653}
654
655fn stage_files_with_index(files: &[String], dir: &str, index_file: Option<&Path>) -> Result<()> {
656   if files.is_empty() {
657      return Ok(());
658   }
659
660   let output = git_command_for_index(index_file)
661      .arg("add")
662      .arg("--")
663      .args(files)
664      .current_dir(dir)
665      .output()
666      .map_err(|e| CommitGenError::git(format!("Failed to stage files: {e}")))?;
667
668   if !output.status.success() {
669      let stderr = String::from_utf8_lossy(&output.stderr);
670      return Err(CommitGenError::git(format!("git add failed: {stderr}")));
671   }
672
673   Ok(())
674}
675
676fn hash_blob_bytes(contents: &[u8], path: &str, dir: &str) -> Result<String> {
677   let mut child = git_command()
678      .args(["hash-object", "-w", "--stdin"])
679      .current_dir(dir)
680      .stdin(std::process::Stdio::piped())
681      .stdout(std::process::Stdio::piped())
682      .stderr(std::process::Stdio::piped())
683      .spawn()
684      .map_err(|e| CommitGenError::git(format!("Failed to spawn git hash-object: {e}")))?;
685
686   {
687      let Some(mut stdin) = child.stdin.take() else {
688         return Err(CommitGenError::git("Failed to open git hash-object stdin".to_string()));
689      };
690
691      use std::io::Write;
692
693      stdin
694         .write_all(contents)
695         .map_err(|e| CommitGenError::git(format!("Failed to write blob for {path}: {e}")))?;
696   }
697
698   let output = child
699      .wait_with_output()
700      .map_err(|e| CommitGenError::git(format!("Failed to wait for git hash-object: {e}")))?;
701
702   if !output.status.success() {
703      let stderr = String::from_utf8_lossy(&output.stderr);
704      return Err(CommitGenError::git(format!("git hash-object failed for {path}: {stderr}")));
705   }
706
707   let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
708   if oid.is_empty() {
709      return Err(CommitGenError::git(format!("git hash-object returned empty oid for {path}")));
710   }
711
712   Ok(oid)
713}
714
715fn index_blob_oid<'a>(blob: &'a IndexBlob, dir: &str) -> Result<Cow<'a, str>> {
716   match &blob.object {
717      IndexObject::BlobContents(contents) => {
718         Ok(Cow::Owned(hash_blob_bytes(contents.as_bytes(), &blob.path, dir)?))
719      },
720      IndexObject::BlobBytes(bytes) => Ok(Cow::Owned(hash_blob_bytes(bytes, &blob.path, dir)?)),
721      IndexObject::ExistingObject(oid) => Ok(Cow::Borrowed(oid.as_str())),
722   }
723}
724
725fn index_entry_matches(
726   path: &str,
727   mode: &str,
728   oid: &str,
729   dir: &str,
730   index_file: Option<&Path>,
731) -> Result<bool> {
732   let output = git_command_for_index(index_file)
733      .args(["ls-files", "-s", "--"])
734      .arg(path)
735      .current_dir(dir)
736      .output()
737      .map_err(|e| CommitGenError::git(format!("Failed to inspect index entry {path}: {e}")))?;
738
739   if !output.status.success() {
740      let stderr = String::from_utf8_lossy(&output.stderr);
741      return Err(CommitGenError::git(format!("git ls-files failed for {path}: {stderr}")));
742   }
743
744   let stdout = String::from_utf8_lossy(&output.stdout);
745   let Some(line) = stdout.lines().next() else {
746      return Ok(false);
747   };
748   let mut parts = line.split_whitespace();
749   Ok(parts.next() == Some(mode) && parts.next() == Some(oid))
750}
751
752fn stage_index_blob(blob: &IndexBlob, dir: &str, index_file: Option<&Path>) -> Result<StageResult> {
753   let oid = index_blob_oid(blob, dir)?;
754   if index_entry_matches(&blob.path, &blob.mode, oid.as_ref(), dir, index_file)? {
755      return Ok(StageResult::AlreadyApplied);
756   }
757
758   let cacheinfo = format!("{},{},{}", blob.mode, oid, blob.path);
759   let output = git_command_for_index(index_file)
760      .args(["update-index", "--add", "--cacheinfo"])
761      .arg(cacheinfo)
762      .current_dir(dir)
763      .output()
764      .map_err(|e| CommitGenError::git(format!("Failed to stage blob {}: {e}", blob.path)))?;
765
766   if !output.status.success() {
767      let stderr = String::from_utf8_lossy(&output.stderr);
768      return Err(CommitGenError::git(format!(
769         "git update-index failed for {}: {stderr}",
770         blob.path
771      )));
772   }
773
774   Ok(StageResult::Staged)
775}
776
777/// Reset staging area.
778#[tracing::instrument(target = "lgit", name = "patch.reset_staging", skip_all, fields(dir))]
779pub fn reset_staging(dir: &str) -> Result<()> {
780   let output = git_command()
781      .args(["reset", "HEAD"])
782      .current_dir(dir)
783      .output()
784      .map_err(|e| CommitGenError::git(format!("Failed to reset staging: {e}")))?;
785
786   if !output.status.success() {
787      let stderr = String::from_utf8_lossy(&output.stderr);
788      return Err(CommitGenError::git(format!("git reset HEAD failed: {stderr}")));
789   }
790
791   Ok(())
792}
793
794fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
795   let trimmed = header.trim();
796   if !trimmed.starts_with("@@") {
797      return None;
798   }
799
800   let after_first = trimmed.strip_prefix("@@")?;
801   let middle = after_first.split("@@").next()?.trim();
802   let parts: Vec<&str> = middle.split_whitespace().collect();
803   if parts.len() < 2 {
804      return None;
805   }
806
807   let old_part = parts[0].strip_prefix('-')?;
808   let new_part = parts[1].strip_prefix('+')?;
809
810   let parse_range = |s: &str| -> Option<(usize, usize)> {
811      if let Some((start, count)) = s.split_once(',') {
812         Some((start.parse().ok()?, count.parse().ok()?))
813      } else {
814         Some((s.parse().ok()?, 1))
815      }
816   };
817
818   let (old_start, old_count) = parse_range(old_part)?;
819   let (new_start, new_count) = parse_range(new_part)?;
820   Some((old_start, old_count, new_start, new_count))
821}
822
823fn parse_file_path(diff_header: &str) -> Result<String> {
824   diff_header
825      .split_whitespace()
826      .nth(3)
827      .and_then(|part| part.strip_prefix("b/"))
828      .map(str::to_string)
829      .ok_or_else(|| {
830         CommitGenError::Other(format!("Failed to parse file path from '{diff_header}'"))
831      })
832}
833
834fn finalize_current_hunk(file: &mut ParsedFile, current_hunk: &mut Option<ParsedHunk>) {
835   if let Some(hunk) = current_hunk.take() {
836      file.hunks.push(hunk);
837   }
838}
839
840fn finalize_current_file(
841   files: &mut Vec<ParsedFile>,
842   current_file: &mut Option<ParsedFile>,
843   current_hunk: &mut Option<ParsedHunk>,
844) {
845   if let Some(mut file) = current_file.take() {
846      finalize_current_hunk(&mut file, current_hunk);
847      files.push(file);
848   }
849}
850
851fn join_lines(lines: &[String]) -> String {
852   if lines.is_empty() {
853      String::new()
854   } else {
855      let mut joined = lines.join("\n");
856      joined.push('\n');
857      joined
858   }
859}
860
861fn diff_lines_preserve_cr(input: &str) -> impl Iterator<Item = &str> {
862   input
863      .split_inclusive('\n')
864      .map(|line| line.strip_suffix('\n').unwrap_or(line))
865}
866
867fn truncate_snippet(snippet: &str, max_chars: usize) -> String {
868   let trimmed = snippet.trim();
869   if trimmed.chars().count() <= max_chars {
870      return trimmed.to_string();
871   }
872
873   let mut truncated = trimmed.chars().take(max_chars).collect::<String>();
874   truncated.push_str("...");
875   truncated
876}
877
878fn build_hunk_snippet(lines: &[String], fallback: &str) -> String {
879   let interesting: Vec<String> = lines
880      .iter()
881      .skip(1)
882      .filter(|line| line.starts_with('+') || line.starts_with('-'))
883      .take(3)
884      .map(|line| truncate_snippet(line.trim_start_matches(['+', '-']), 80))
885      .collect();
886
887   if interesting.is_empty() {
888      truncate_snippet(fallback, 80)
889   } else {
890      interesting.join(" | ")
891   }
892}
893
894fn build_synthetic_snippet(file: &ParsedFile) -> String {
895   let header_text = file
896      .header_lines
897      .iter()
898      .skip(1)
899      .find(|line| {
900         !line.starts_with("index ")
901            && !line.starts_with("--- ")
902            && !line.starts_with("+++ ")
903            && !line.trim().is_empty()
904      })
905      .cloned()
906      .unwrap_or_else(|| format!("whole-file change in {}", file.path));
907
908   truncate_snippet(&header_text, 80)
909}
910
911fn fnv1a_64(input: &str) -> String {
912   let mut hash = 0xcbf29ce484222325_u64;
913   for byte in input.as_bytes() {
914      hash ^= u64::from(*byte);
915      hash = hash.wrapping_mul(0x100000001b3);
916   }
917   format!("{hash:016x}")
918}
919
920fn build_semantic_key(path: &str, lines: &[String], fallback: &str) -> String {
921   let mut changed = Vec::new();
922   for line in lines {
923      if (line.starts_with('+') && !line.starts_with("+++"))
924         || (line.starts_with('-') && !line.starts_with("---"))
925      {
926         changed.push(line.clone());
927      }
928   }
929
930   let source = if changed.is_empty() {
931      fallback.to_string()
932   } else {
933      changed.join("\n")
934   };
935
936   format!("{path}:{}", fnv1a_64(&source))
937}
938
939#[tracing::instrument(target = "lgit", name = "patch.build_compose_snapshot", skip_all, fields(diff_bytes = diff.len(), stat_bytes = stat.len()))]
940pub fn build_compose_snapshot(diff: &str, stat: &str) -> Result<ComposeSnapshot> {
941   let mut files = Vec::new();
942   let mut current_file: Option<ParsedFile> = None;
943   let mut current_hunk: Option<ParsedHunk> = None;
944
945   for line in diff_lines_preserve_cr(diff) {
946      if line.starts_with("diff --git ") {
947         finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
948         current_file = Some(ParsedFile {
949            path:         parse_file_path(line)?,
950            header_lines: vec![line.to_string()],
951            hunks:        Vec::new(),
952            additions:    0,
953            deletions:    0,
954            is_binary:    false,
955         });
956         continue;
957      }
958
959      let Some(file) = &mut current_file else {
960         continue;
961      };
962
963      if line.starts_with("@@ ") {
964         finalize_current_hunk(file, &mut current_hunk);
965         let (old_start, old_count, new_start, new_count) =
966            parse_hunk_header(line).ok_or_else(|| {
967               CommitGenError::Other(format!("Failed to parse hunk header '{line}'"))
968            })?;
969         current_hunk = Some(ParsedHunk {
970            old_start,
971            old_count,
972            new_start,
973            new_count,
974            header: line.to_string(),
975            lines: vec![line.to_string()],
976         });
977         continue;
978      }
979
980      if let Some(hunk) = &mut current_hunk {
981         if line.starts_with('+') {
982            file.additions += 1;
983         } else if line.starts_with('-') {
984            file.deletions += 1;
985         }
986
987         hunk.lines.push(line.to_string());
988         continue;
989      }
990
991      if line.starts_with("Binary files ") {
992         file.is_binary = true;
993      }
994      file.header_lines.push(line.to_string());
995   }
996
997   finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
998
999   let mut snapshot_files = Vec::new();
1000   let mut snapshot_hunks = Vec::new();
1001
1002   for (file_index, file) in files.into_iter().enumerate() {
1003      let file_id = format!("F{:03}", file_index + 1);
1004      let patch_header = join_lines(&file.header_lines);
1005      let mut full_patch = patch_header.clone();
1006      let mut hunk_ids = Vec::new();
1007
1008      if file.hunks.is_empty() {
1009         let hunk_id = format!("{file_id}-H001");
1010         let snippet = build_synthetic_snippet(&file);
1011         let semantic_key = build_semantic_key(&file.path, &file.header_lines, &snippet);
1012         hunk_ids.push(hunk_id.clone());
1013         snapshot_hunks.push(ComposeHunk {
1014            hunk_id,
1015            file_id: file_id.clone(),
1016            path: file.path.clone(),
1017            old_start: 0,
1018            old_count: 0,
1019            new_start: 0,
1020            new_count: 0,
1021            header: snippet.clone(),
1022            raw_patch: String::new(),
1023            snippet,
1024            semantic_key,
1025            synthetic: true,
1026         });
1027      } else {
1028         for (hunk_index, hunk) in file.hunks.iter().enumerate() {
1029            let hunk_id = format!("{file_id}-H{:03}", hunk_index + 1);
1030            let raw_patch = join_lines(&hunk.lines);
1031            let snippet = build_hunk_snippet(&hunk.lines, &hunk.header);
1032            let semantic_key = build_semantic_key(&file.path, &hunk.lines, &snippet);
1033
1034            full_patch.push_str(&raw_patch);
1035            hunk_ids.push(hunk_id.clone());
1036            snapshot_hunks.push(ComposeHunk {
1037               hunk_id,
1038               file_id: file_id.clone(),
1039               path: file.path.clone(),
1040               old_start: hunk.old_start,
1041               old_count: hunk.old_count,
1042               new_start: hunk.new_start,
1043               new_count: hunk.new_count,
1044               header: hunk.header.clone(),
1045               raw_patch,
1046               snippet,
1047               semantic_key,
1048               synthetic: false,
1049            });
1050         }
1051      }
1052
1053      let hunk_word = if hunk_ids.len() == 1 { "hunk" } else { "hunks" };
1054      let summary = format!(
1055         "{} (+{}/-{}, {} {})",
1056         file.path,
1057         file.additions,
1058         file.deletions,
1059         hunk_ids.len(),
1060         hunk_word
1061      );
1062
1063      snapshot_files.push(ComposeFile {
1064         file_id,
1065         path: file.path,
1066         patch_header,
1067         full_patch,
1068         summary,
1069         hunk_ids,
1070         additions: file.additions,
1071         deletions: file.deletions,
1072         is_binary: file.is_binary,
1073         synthetic_only: file.hunks.is_empty(),
1074      });
1075   }
1076
1077   Ok(ComposeSnapshot {
1078      diff:  diff.to_string(),
1079      stat:  stat.to_string(),
1080      files: snapshot_files,
1081      hunks: snapshot_hunks,
1082      pins:  BTreeMap::new(),
1083   })
1084}
1085
1086fn create_patch_for_file(file: &ComposeFile, hunks: &[&ComposeHunk]) -> String {
1087   let mut patch = file.patch_header.clone();
1088   for hunk in hunks {
1089      patch.push_str(&hunk.raw_patch);
1090   }
1091   patch
1092}
1093
1094fn selected_hunks_by_file<'a>(
1095   snapshot: &'a ComposeSnapshot,
1096   group: &ComposeExecutableGroup,
1097) -> Result<BTreeMap<String, Vec<&'a ComposeHunk>>> {
1098   if group.hunk_ids.is_empty() {
1099      return Err(CommitGenError::Other(format!("Group {} has no assigned hunks", group.group_id)));
1100   }
1101
1102   let mut selected_by_file: BTreeMap<String, Vec<&ComposeHunk>> = BTreeMap::new();
1103   for hunk_id in &group.hunk_ids {
1104      let hunk = snapshot.hunk_by_id(hunk_id).ok_or_else(|| {
1105         CommitGenError::Other(format!(
1106            "Group {} references unknown hunk id {hunk_id}",
1107            group.group_id
1108         ))
1109      })?;
1110      selected_by_file
1111         .entry(hunk.file_id.clone())
1112         .or_default()
1113         .push(hunk);
1114   }
1115
1116   Ok(selected_by_file)
1117}
1118
1119fn ordered_selected_hunks<'a>(
1120   file: &ComposeFile,
1121   selected_for_file: &[&'a ComposeHunk],
1122) -> Result<Vec<&'a ComposeHunk>> {
1123   let ordered_hunks: Vec<&ComposeHunk> = file
1124      .hunk_ids
1125      .iter()
1126      .filter_map(|hunk_id| {
1127         selected_for_file
1128            .iter()
1129            .find(|hunk| hunk.hunk_id == *hunk_id)
1130            .copied()
1131      })
1132      .collect();
1133
1134   if ordered_hunks.is_empty() {
1135      return Err(CommitGenError::Other(format!("Selected no patchable hunks for {}", file.path)));
1136   }
1137
1138   Ok(ordered_hunks)
1139}
1140
1141fn selected_hunks_cover_file(file: &ComposeFile, selected_for_file: &[&ComposeHunk]) -> bool {
1142   let selected_ids: HashSet<&str> = selected_for_file
1143      .iter()
1144      .map(|hunk| hunk.hunk_id.as_str())
1145      .collect();
1146   let file_hunk_ids: HashSet<&str> = file.hunk_ids.iter().map(String::as_str).collect();
1147   selected_ids == file_hunk_ids
1148}
1149
1150fn count_hunk_changes(hunk: &ComposeHunk) -> (usize, usize) {
1151   let mut additions = 0_usize;
1152   let mut deletions = 0_usize;
1153
1154   for line in hunk.raw_patch.lines() {
1155      if line.starts_with('+') {
1156         additions += 1;
1157      } else if line.starts_with('-') {
1158         deletions += 1;
1159      }
1160   }
1161
1162   (additions, deletions)
1163}
1164
1165fn push_stat_line(
1166   stat: &mut String,
1167   path: &str,
1168   additions: usize,
1169   deletions: usize,
1170   is_binary: bool,
1171) {
1172   use std::fmt::Write;
1173
1174   if is_binary && additions == 0 && deletions == 0 {
1175      writeln!(stat, " {path} | Bin").unwrap();
1176      return;
1177   }
1178
1179   let change_count = additions + deletions;
1180   let pluses = "+".repeat(additions.min(50));
1181   let minuses = "-".repeat(deletions.min(50));
1182   writeln!(stat, " {path} | {change_count} {pluses}{minuses}").unwrap();
1183}
1184
1185fn new_file_mode(file: &ComposeFile) -> Option<&str> {
1186   file
1187      .patch_header
1188      .lines()
1189      .find_map(|line| line.strip_prefix("new file mode ").map(str::trim))
1190}
1191
1192fn validate_new_file_mode(file: &ComposeFile) -> Result<String> {
1193   let mode = new_file_mode(file).unwrap_or("100644");
1194   if matches!(mode, "100644" | "100755" | "120000" | "160000") {
1195      Ok(mode.to_string())
1196   } else {
1197      Err(CommitGenError::Other(format!("Invalid new file mode {mode:?} for {}", file.path)))
1198   }
1199}
1200
1201fn materialize_new_file_contents(hunks: &[&ComposeHunk]) -> String {
1202   let mut contents = String::new();
1203   let mut last_emitted_line_had_newline = false;
1204
1205   for hunk in hunks {
1206      for line in diff_lines_preserve_cr(&hunk.raw_patch) {
1207         if line.starts_with("@@") {
1208            last_emitted_line_had_newline = false;
1209            continue;
1210         }
1211
1212         if line == r"\ No newline at end of file" {
1213            if last_emitted_line_had_newline {
1214               contents.pop();
1215               last_emitted_line_had_newline = false;
1216            }
1217            continue;
1218         }
1219
1220         if let Some(added) = line.strip_prefix('+') {
1221            contents.push_str(added);
1222            contents.push('\n');
1223            last_emitted_line_had_newline = true;
1224         } else if let Some(context) = line.strip_prefix(' ') {
1225            contents.push_str(context);
1226            contents.push('\n');
1227            last_emitted_line_had_newline = true;
1228         } else {
1229            last_emitted_line_had_newline = false;
1230         }
1231      }
1232   }
1233
1234   contents
1235}
1236
1237fn new_file_index_oid(file: &ComposeFile) -> Option<&str> {
1238   file.patch_header.lines().find_map(|line| {
1239      let index_range = line.strip_prefix("index ")?;
1240      let (_, new_oid) = index_range.split_once("..")?;
1241      new_oid.split_whitespace().next()
1242   })
1243}
1244
1245fn validate_git_object_id(oid: &str, file: &ComposeFile) -> Result<String> {
1246   let oid = oid.trim();
1247   if !oid.is_empty()
1248      && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
1249      && oid.bytes().any(|byte| byte != b'0')
1250   {
1251      Ok(oid.to_string())
1252   } else {
1253      Err(CommitGenError::Other(format!("Invalid gitlink object id {oid:?} for {}", file.path)))
1254   }
1255}
1256
1257fn materialize_gitlink_oid(file: &ComposeFile, hunks: &[&ComposeHunk]) -> Result<String> {
1258   let contents = materialize_new_file_contents(hunks);
1259   if let Some(oid) = contents.lines().find_map(|line| {
1260      line
1261         .strip_prefix("Subproject commit ")
1262         .and_then(|rest| rest.split_whitespace().next())
1263   }) {
1264      return validate_git_object_id(oid, file);
1265   }
1266
1267   if let Some(oid) = new_file_index_oid(file) {
1268      return validate_git_object_id(oid, file);
1269   }
1270
1271   Err(CommitGenError::Other(format!("Missing gitlink object id for {}", file.path)))
1272}
1273
1274fn new_file_index_blob(file: &ComposeFile, hunks: &[&ComposeHunk]) -> Result<IndexBlob> {
1275   let mode = validate_new_file_mode(file)?;
1276   let object = if mode == "160000" {
1277      IndexObject::ExistingObject(materialize_gitlink_oid(file, hunks)?)
1278   } else {
1279      IndexObject::BlobContents(materialize_new_file_contents(hunks))
1280   };
1281
1282   Ok(IndexBlob { path: file.path.clone(), mode, object })
1283}
1284
1285#[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()))]
1286pub fn create_executable_group_patch(
1287   snapshot: &ComposeSnapshot,
1288   group: &ComposeExecutableGroup,
1289) -> Result<ComposeGroupPatch> {
1290   let selected_by_file = selected_hunks_by_file(snapshot, group)?;
1291   let mut fallback_files = Vec::new();
1292   let mut diff = String::new();
1293   let mut stat = String::new();
1294   let mut apply_patches: Vec<FilePatch> = Vec::new();
1295   let mut index_blobs = Vec::new();
1296
1297   for file in &snapshot.files {
1298      let Some(selected_for_file) = selected_by_file.get(&file.file_id) else {
1299         continue;
1300      };
1301
1302      let ordered_hunks = ordered_selected_hunks(file, selected_for_file).map_err(|_| {
1303         CommitGenError::Other(format!(
1304            "Group {} selected no patchable hunks for {}",
1305            group.group_id, file.path
1306         ))
1307      })?;
1308
1309      if file.synthetic_only || file.is_binary {
1310         if selected_hunks_cover_file(file, selected_for_file) {
1311            if file.synthetic_only && !file.is_binary && new_file_mode(file).is_some() {
1312               index_blobs.push(new_file_index_blob(file, &ordered_hunks)?);
1313            } else {
1314               fallback_files.push(file.path.clone());
1315            }
1316            diff.push_str(&file.full_patch);
1317            push_stat_line(&mut stat, &file.path, file.additions, file.deletions, file.is_binary);
1318            continue;
1319         }
1320
1321         return Err(CommitGenError::Other(format!(
1322            "Group {} cannot partially stage unpatchable file {}",
1323            group.group_id, file.path
1324         )));
1325      }
1326
1327      let file_patch = create_patch_for_file(file, &ordered_hunks);
1328      let (additions, deletions) = ordered_hunks.iter().fold(
1329         (0_usize, 0_usize),
1330         |(total_additions, total_deletions), hunk| {
1331            let (hunk_additions, hunk_deletions) = count_hunk_changes(hunk);
1332            (total_additions + hunk_additions, total_deletions + hunk_deletions)
1333         },
1334      );
1335      diff.push_str(&file_patch);
1336      if new_file_mode(file).is_some() {
1337         // New files (and submodule gitlinks) keep their existing handling:
1338         // covers-all builds the blob from the diff; partial falls back to apply.
1339         if selected_hunks_cover_file(file, selected_for_file) {
1340            index_blobs.push(new_file_index_blob(file, &ordered_hunks)?);
1341         } else {
1342            apply_patches.push(FilePatch { path: file.path.clone(), patch: file_patch });
1343         }
1344      } else if selected_hunks_cover_file(file, selected_for_file) {
1345         // Whole-file change: stage straight from the working tree. No patch is
1346         // reconstructed or applied, so line-ending/whitespace normalization can
1347         // never make git reject its own diff.
1348         fallback_files.push(file.path.clone());
1349      } else {
1350         // Partial change to a shared file: apply just these hunks; if the apply
1351         // is refused, the caller re-stages from base via splice.
1352         apply_patches.push(FilePatch { path: file.path.clone(), patch: file_patch });
1353      }
1354      push_stat_line(&mut stat, &file.path, additions, deletions, false);
1355   }
1356
1357   fallback_files.sort();
1358   fallback_files.dedup();
1359
1360   Ok(ComposeGroupPatch { diff, stat, apply_patches, fallback_files, index_blobs })
1361}
1362
1363#[tracing::instrument(target = "lgit", name = "patch.stage_executable_group", skip_all, fields(dir, group_id = %group.group_id))]
1364pub fn stage_executable_group(
1365   snapshot: &ComposeSnapshot,
1366   group: &ComposeExecutableGroup,
1367   dir: &str,
1368) -> Result<ComposeStageOutcome> {
1369   stage_executable_group_with_index(snapshot, group, dir, None)
1370}
1371
1372#[tracing::instrument(target = "lgit", name = "patch.stage_executable_group_in_index", skip_all, fields(dir, group_id = %group.group_id, index = %index_file.display()))]
1373pub fn stage_executable_group_in_index(
1374   snapshot: &ComposeSnapshot,
1375   group: &ComposeExecutableGroup,
1376   dir: &str,
1377   index_file: &Path,
1378) -> Result<ComposeStageOutcome> {
1379   stage_executable_group_with_index(snapshot, group, dir, Some(index_file))
1380}
1381
1382fn stage_executable_group_with_index(
1383   snapshot: &ComposeSnapshot,
1384   group: &ComposeExecutableGroup,
1385   dir: &str,
1386   index_file: Option<&Path>,
1387) -> Result<ComposeStageOutcome> {
1388   let group_patch = create_executable_group_patch(snapshot, group)?;
1389   let mut result = StageResult::EmptyPatch;
1390   let mut skipped = Vec::new();
1391
1392   for file_patch in &group_patch.apply_patches {
1393      match apply_file_patch_to_index(&file_patch.patch, dir, index_file)? {
1394         FilePatchOutcome::Staged => result = result.combine(StageResult::Staged),
1395         FilePatchOutcome::AlreadyApplied => {
1396            result = result.combine(StageResult::AlreadyApplied);
1397         },
1398         FilePatchOutcome::Empty => result = result.combine(StageResult::EmptyPatch),
1399         FilePatchOutcome::Failed(reason) => {
1400            // The planned patch no longer applies against the current state.
1401            // Drop any conflicted index residue and keep the worktree change.
1402            restore_index_path_to_head(&file_patch.path, dir, index_file)?;
1403            skipped.push(SkippedFile { path: file_patch.path.clone(), reason });
1404         },
1405      }
1406   }
1407
1408   for path in &group_patch.fallback_files {
1409      match snapshot.pins.get(path) {
1410         Some(WorktreePin::Object { mode, oid }) => {
1411            let blob = IndexBlob {
1412               path:   path.clone(),
1413               mode:   mode.clone(),
1414               object: IndexObject::ExistingObject(oid.clone()),
1415            };
1416            result = result.combine(stage_index_blob(&blob, dir, index_file)?);
1417         },
1418         Some(WorktreePin::Deleted) => {
1419            result = result.combine(remove_index_path(path, dir, index_file)?);
1420         },
1421         // Unpinned snapshot (tests, legacy callers): stage from the live
1422         // worktree as before.
1423         None => {
1424            stage_files_with_index(std::slice::from_ref(path), dir, index_file)?;
1425            result = result.combine(StageResult::Staged);
1426         },
1427      }
1428   }
1429
1430   for blob in &group_patch.index_blobs {
1431      result = result.combine(stage_index_blob(blob, dir, index_file)?);
1432   }
1433
1434   Ok(ComposeStageOutcome { result, skipped })
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439   use std::fs;
1440
1441   use tempfile::TempDir;
1442
1443   use super::*;
1444   use crate::{
1445      compose_types::ComposeExecutableGroup,
1446      git::{TempGitIndex, get_compose_diff, get_compose_stat, read_tree_into_index},
1447      types::CommitType,
1448   };
1449
1450   fn write_file(dir: &TempDir, path: &str, contents: &str) {
1451      let full_path = dir.path().join(path);
1452      if let Some(parent) = full_path.parent() {
1453         fs::create_dir_all(parent).unwrap();
1454      }
1455      fs::write(full_path, contents).unwrap();
1456   }
1457
1458   fn run_git(dir: &TempDir, args: &[&str]) -> String {
1459      let output = git_command()
1460         .args(args)
1461         .current_dir(dir.path())
1462         .output()
1463         .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}"));
1464
1465      assert!(
1466         output.status.success(),
1467         "git {:?} failed: stdout={} stderr={}",
1468         args,
1469         String::from_utf8_lossy(&output.stdout),
1470         String::from_utf8_lossy(&output.stderr)
1471      );
1472
1473      String::from_utf8_lossy(&output.stdout).to_string()
1474   }
1475
1476   fn init_repo() -> TempDir {
1477      let dir = TempDir::new().unwrap();
1478      run_git(&dir, &["init"]);
1479      run_git(&dir, &["config", "user.name", "Compose Test"]);
1480      run_git(&dir, &["config", "user.email", "compose@test.local"]);
1481      run_git(&dir, &["config", "commit.gpgsign", "false"]);
1482      dir
1483   }
1484
1485   fn fixture_file_original() -> String {
1486      [
1487         "fn alpha() {",
1488         "    println!(\"alpha\");",
1489         "}",
1490         "",
1491         "// spacer 1",
1492         "// spacer 2",
1493         "// spacer 3",
1494         "// spacer 4",
1495         "// spacer 5",
1496         "// spacer 6",
1497         "// spacer 7",
1498         "// spacer 8",
1499         "fn beta() {",
1500         "    println!(\"beta\");",
1501         "}",
1502         "",
1503      ]
1504      .join("\n")
1505   }
1506
1507   fn fixture_file_stage_only() -> String {
1508      fixture_file_original().replace("alpha", "alpha staged")
1509   }
1510
1511   fn fixture_file_stage_and_unstaged() -> String {
1512      fixture_file_stage_only().replace("beta", "beta unstaged")
1513   }
1514
1515   fn fixture_file_two_hunks() -> String {
1516      [
1517         "fn alpha() {",
1518         "    println!(\"alpha changed\");",
1519         "}",
1520         "",
1521         "// spacer 1",
1522         "// spacer 2",
1523         "// spacer 3",
1524         "// spacer 4",
1525         "// spacer 5",
1526         "// spacer 6",
1527         "// spacer 7",
1528         "// spacer 8",
1529         "fn beta() {",
1530         "    println!(\"beta changed\");",
1531         "}",
1532         "",
1533      ]
1534      .join("\n")
1535   }
1536
1537   fn commit_all(dir: &TempDir, message: &str) {
1538      run_git(dir, &["add", "."]);
1539      run_git(dir, &["commit", "-m", message]);
1540   }
1541
1542   fn staged_diff(dir: &TempDir) -> String {
1543      run_git(dir, &["diff", "--cached"])
1544   }
1545
1546   fn staged_diff_in_index(dir: &TempDir, index: &TempGitIndex) -> String {
1547      let output = crate::git::git_command_with_index(index.path())
1548         .args(["diff", "--cached"])
1549         .current_dir(dir.path())
1550         .output()
1551         .unwrap();
1552      assert!(
1553         output.status.success(),
1554         "git diff --cached with temp index failed: {}",
1555         String::from_utf8_lossy(&output.stderr)
1556      );
1557      String::from_utf8_lossy(&output.stdout).to_string()
1558   }
1559
1560   #[test]
1561   fn test_build_compose_snapshot_stable_ids() {
1562      let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1563index 1111111..2222222 100644
1564--- a/src/lib.rs
1565+++ b/src/lib.rs
1566@@ -1,3 +1,3 @@
1567-fn alpha() {
1568+fn alpha_changed() {
1569     println!("alpha");
1570 }
1571diff --git a/tests/lib.rs b/tests/lib.rs
1572index 3333333..4444444 100644
1573--- a/tests/lib.rs
1574+++ b/tests/lib.rs
1575@@ -10,3 +10,4 @@
1576 fn test_it() {
1577+    assert!(true);
1578 }
1579"#;
1580
1581      let stat = " src/lib.rs | 2 +-\n tests/lib.rs | 1 +\n";
1582      let first = build_compose_snapshot(diff, stat).unwrap();
1583      let second = build_compose_snapshot(diff, stat).unwrap();
1584
1585      assert_eq!(first.files.len(), 2);
1586      assert_eq!(
1587         first
1588            .files
1589            .iter()
1590            .map(|file| file.file_id.clone())
1591            .collect::<Vec<_>>(),
1592         second
1593            .files
1594            .iter()
1595            .map(|file| file.file_id.clone())
1596            .collect::<Vec<_>>()
1597      );
1598      assert_eq!(
1599         first
1600            .hunks
1601            .iter()
1602            .map(|hunk| hunk.hunk_id.clone())
1603            .collect::<Vec<_>>(),
1604         second
1605            .hunks
1606            .iter()
1607            .map(|hunk| hunk.hunk_id.clone())
1608            .collect::<Vec<_>>()
1609      );
1610   }
1611
1612   #[test]
1613   fn test_get_compose_diff_merges_staged_unstaged_and_untracked() {
1614      let dir = init_repo();
1615      write_file(&dir, "src/lib.rs", &fixture_file_original());
1616      commit_all(&dir, "initial");
1617
1618      write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1619      run_git(&dir, &["add", "src/lib.rs"]);
1620      write_file(&dir, "src/lib.rs", &fixture_file_stage_and_unstaged());
1621      write_file(&dir, "notes.txt", "new untracked file\n");
1622
1623      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1624      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1625      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1626
1627      assert_eq!(snapshot.files.len(), 2);
1628      assert!(snapshot.file_by_path("src/lib.rs").is_some());
1629      assert!(snapshot.file_by_path("notes.txt").is_some());
1630
1631      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1632      assert!(
1633         source_file.hunk_ids.len() >= 2,
1634         "expected staged + unstaged edits in one file to produce multiple hunks"
1635      );
1636   }
1637
1638   #[test]
1639   fn test_stage_executable_group_partial_hunk_from_one_file() {
1640      let dir = init_repo();
1641      write_file(&dir, "src/lib.rs", &fixture_file_original());
1642      commit_all(&dir, "initial");
1643      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1644
1645      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1646      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1647      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1648      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1649      assert_eq!(source_file.hunk_ids.len(), 2);
1650
1651      reset_staging(dir.path().to_str().unwrap()).unwrap();
1652      let group = ComposeExecutableGroup {
1653         group_id:     "G1".to_string(),
1654         commit_type:  CommitType::new("refactor").unwrap(),
1655         scope:        None,
1656         file_ids:     vec![source_file.file_id.clone()],
1657         rationale:    "first hunk".to_string(),
1658         dependencies: vec![],
1659         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
1660      };
1661      stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1662
1663      let staged = staged_diff(&dir);
1664      assert!(staged.contains("alpha changed"));
1665      assert!(!staged.contains("beta changed"));
1666   }
1667
1668   #[test]
1669   fn test_stage_executable_group_across_sequential_commits_same_file() {
1670      let dir = init_repo();
1671      write_file(&dir, "src/lib.rs", &fixture_file_original());
1672      commit_all(&dir, "initial");
1673      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1674
1675      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1676      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1677      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1678      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1679      assert_eq!(source_file.hunk_ids.len(), 2);
1680
1681      let first_group = ComposeExecutableGroup {
1682         group_id:     "G1".to_string(),
1683         commit_type:  CommitType::new("refactor").unwrap(),
1684         scope:        None,
1685         file_ids:     vec![source_file.file_id.clone()],
1686         rationale:    "first hunk".to_string(),
1687         dependencies: vec![],
1688         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
1689      };
1690      let second_group = ComposeExecutableGroup {
1691         group_id:     "G2".to_string(),
1692         commit_type:  CommitType::new("refactor").unwrap(),
1693         scope:        None,
1694         file_ids:     vec![source_file.file_id.clone()],
1695         rationale:    "second hunk".to_string(),
1696         dependencies: vec![],
1697         hunk_ids:     vec![source_file.hunk_ids[1].clone()],
1698      };
1699
1700      reset_staging(dir.path().to_str().unwrap()).unwrap();
1701      stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
1702      run_git(&dir, &["commit", "-m", "first"]);
1703
1704      stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
1705      let staged = staged_diff(&dir);
1706      assert!(staged.contains("beta changed"));
1707      assert!(!staged.contains("alpha changed"));
1708   }
1709
1710   fn show_index_blob(dir: &TempDir, index: &TempGitIndex, path: &str) -> String {
1711      let output = crate::git::git_command_with_index(index.path())
1712         .args(["show", &format!(":{path}")])
1713         .current_dir(dir.path())
1714         .output()
1715         .unwrap();
1716      assert!(
1717         output.status.success(),
1718         "git show :{path} failed: {}",
1719         String::from_utf8_lossy(&output.stderr)
1720      );
1721      String::from_utf8_lossy(&output.stdout).to_string()
1722   }
1723
1724   #[test]
1725   fn test_pinned_staging_ignores_worktree_edits_after_snapshot() {
1726      let dir = init_repo();
1727      write_file(&dir, "src/lib.rs", &fixture_file_original());
1728      commit_all(&dir, "initial");
1729      write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1730
1731      let dir_str = dir.path().to_str().unwrap();
1732      let diff = get_compose_diff(dir_str).unwrap();
1733      let stat = get_compose_stat(dir_str).unwrap();
1734      let mut snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1735      pin_snapshot_worktree_state(&mut snapshot, dir_str).unwrap();
1736
1737      // The user keeps editing while compose generates messages.
1738      write_file(&dir, "src/lib.rs", &fixture_file_stage_and_unstaged());
1739
1740      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1741      let group = ComposeExecutableGroup {
1742         group_id:     "G1".to_string(),
1743         commit_type:  CommitType::new("refactor").unwrap(),
1744         scope:        None,
1745         file_ids:     vec![source_file.file_id.clone()],
1746         rationale:    "whole file".to_string(),
1747         dependencies: vec![],
1748         hunk_ids:     source_file.hunk_ids.clone(),
1749      };
1750
1751      let index = TempGitIndex::new(dir_str).unwrap();
1752      read_tree_into_index(index.path(), "HEAD", dir_str).unwrap();
1753      let outcome =
1754         stage_executable_group_in_index(&snapshot, &group, dir_str, index.path()).unwrap();
1755      assert_eq!(outcome.result, StageResult::Staged);
1756      assert!(outcome.skipped.is_empty());
1757
1758      let staged = show_index_blob(&dir, &index, "src/lib.rs");
1759      assert_eq!(
1760         staged,
1761         fixture_file_stage_only(),
1762         "staged content must match the pinned snapshot, not the live worktree"
1763      );
1764
1765      let on_disk = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
1766      assert_eq!(
1767         on_disk,
1768         fixture_file_stage_and_unstaged(),
1769         "later edits stay untouched in the worktree"
1770      );
1771   }
1772
1773   #[test]
1774   fn test_pinned_staging_stages_deletion_even_if_file_recreated() {
1775      let dir = init_repo();
1776      write_file(&dir, "src/lib.rs", &fixture_file_original());
1777      commit_all(&dir, "initial");
1778      fs::remove_file(dir.path().join("src/lib.rs")).unwrap();
1779
1780      let dir_str = dir.path().to_str().unwrap();
1781      let diff = get_compose_diff(dir_str).unwrap();
1782      let stat = get_compose_stat(dir_str).unwrap();
1783      let mut snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1784      pin_snapshot_worktree_state(&mut snapshot, dir_str).unwrap();
1785      assert_eq!(
1786         snapshot.pins.get("src/lib.rs"),
1787         Some(&crate::compose_types::WorktreePin::Deleted)
1788      );
1789
1790      // The file reappears while compose runs.
1791      write_file(&dir, "src/lib.rs", "fn revived() {}\n");
1792
1793      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1794      let group = ComposeExecutableGroup {
1795         group_id:     "G1".to_string(),
1796         commit_type:  CommitType::new("refactor").unwrap(),
1797         scope:        None,
1798         file_ids:     vec![source_file.file_id.clone()],
1799         rationale:    "delete file".to_string(),
1800         dependencies: vec![],
1801         hunk_ids:     source_file.hunk_ids.clone(),
1802      };
1803
1804      let index = TempGitIndex::new(dir_str).unwrap();
1805      read_tree_into_index(index.path(), "HEAD", dir_str).unwrap();
1806      let outcome =
1807         stage_executable_group_in_index(&snapshot, &group, dir_str, index.path()).unwrap();
1808      assert_eq!(outcome.result, StageResult::Staged);
1809
1810      let listed = crate::git::git_command_with_index(index.path())
1811         .args(["ls-files", "--", "src/lib.rs"])
1812         .current_dir(dir.path())
1813         .output()
1814         .unwrap();
1815      assert!(listed.stdout.is_empty(), "deletion must be staged from the pin");
1816      assert!(
1817         dir.path().join("src/lib.rs").exists(),
1818         "recreated file stays untouched in the worktree"
1819      );
1820   }
1821
1822   #[test]
1823   fn test_create_executable_group_patch_derives_diff_without_staging() {
1824      let dir = init_repo();
1825      write_file(&dir, "src/lib.rs", &fixture_file_original());
1826      commit_all(&dir, "initial");
1827      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1828
1829      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1830      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1831      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1832      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1833      let group = ComposeExecutableGroup {
1834         group_id:     "G1".to_string(),
1835         commit_type:  CommitType::new("refactor").unwrap(),
1836         scope:        None,
1837         file_ids:     vec![source_file.file_id.clone()],
1838         rationale:    "first hunk".to_string(),
1839         dependencies: vec![],
1840         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
1841      };
1842
1843      reset_staging(dir.path().to_str().unwrap()).unwrap();
1844      let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
1845
1846      assert!(staged_diff(&dir).trim().is_empty());
1847      assert!(group_patch.diff.contains("alpha changed"));
1848      assert!(!group_patch.diff.contains("beta changed"));
1849      assert!(group_patch.stat.contains("src/lib.rs | 2 +-"));
1850   }
1851
1852   #[test]
1853   fn test_stage_executable_groups_ignore_unplanned_files_between_commits() {
1854      let dir = init_repo();
1855      write_file(&dir, "src/a.rs", "fn a() {}\n");
1856      write_file(&dir, "src/b.rs", "fn b() {}\n");
1857      commit_all(&dir, "initial");
1858      write_file(&dir, "src/a.rs", "fn a_changed() {}\n");
1859      write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
1860
1861      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1862      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1863      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1864      let first_file = snapshot.file_by_path("src/a.rs").unwrap();
1865      let second_file = snapshot.file_by_path("src/b.rs").unwrap();
1866      let first_group = ComposeExecutableGroup {
1867         group_id:     "G1".to_string(),
1868         commit_type:  CommitType::new("refactor").unwrap(),
1869         scope:        None,
1870         file_ids:     vec![first_file.file_id.clone()],
1871         rationale:    "first file".to_string(),
1872         dependencies: vec![],
1873         hunk_ids:     first_file.hunk_ids.clone(),
1874      };
1875      let second_group = ComposeExecutableGroup {
1876         group_id:     "G2".to_string(),
1877         commit_type:  CommitType::new("refactor").unwrap(),
1878         scope:        None,
1879         file_ids:     vec![second_file.file_id.clone()],
1880         rationale:    "second file".to_string(),
1881         dependencies: vec![],
1882         hunk_ids:     second_file.hunk_ids.clone(),
1883      };
1884
1885      reset_staging(dir.path().to_str().unwrap()).unwrap();
1886      assert_eq!(
1887         stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap())
1888            .unwrap()
1889            .result,
1890         StageResult::Staged
1891      );
1892      run_git(&dir, &["commit", "-m", "first"]);
1893      write_file(&dir, "Dockerfile", "FROM scratch\n");
1894
1895      assert_eq!(
1896         stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap())
1897            .unwrap()
1898            .result,
1899         StageResult::Staged
1900      );
1901      let staged = staged_diff(&dir);
1902      assert!(staged.contains("b_changed"));
1903      assert!(!staged.contains("Dockerfile"));
1904      run_git(&dir, &["commit", "-m", "second"]);
1905
1906      assert!(
1907         get_compose_diff(dir.path().to_str().unwrap())
1908            .unwrap()
1909            .contains("Dockerfile")
1910      );
1911   }
1912
1913   #[test]
1914   fn test_stage_executable_group_ignores_same_file_local_edit_between_commits() {
1915      let dir = init_repo();
1916      write_file(&dir, "src/lib.rs", &fixture_file_original());
1917      commit_all(&dir, "initial");
1918      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1919
1920      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1921      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1922      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1923      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1924      let first_group = ComposeExecutableGroup {
1925         group_id:     "G1".to_string(),
1926         commit_type:  CommitType::new("refactor").unwrap(),
1927         scope:        None,
1928         file_ids:     vec![source_file.file_id.clone()],
1929         rationale:    "first hunk".to_string(),
1930         dependencies: vec![],
1931         hunk_ids:     vec![source_file.hunk_ids[0].clone()],
1932      };
1933      let second_group = ComposeExecutableGroup {
1934         group_id:     "G2".to_string(),
1935         commit_type:  CommitType::new("refactor").unwrap(),
1936         scope:        None,
1937         file_ids:     vec![source_file.file_id.clone()],
1938         rationale:    "second hunk".to_string(),
1939         dependencies: vec![],
1940         hunk_ids:     vec![source_file.hunk_ids[1].clone()],
1941      };
1942
1943      reset_staging(dir.path().to_str().unwrap()).unwrap();
1944      stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
1945      run_git(&dir, &["commit", "-m", "first"]);
1946      write_file(
1947         &dir,
1948         "src/lib.rs",
1949         &fixture_file_two_hunks().replace("// spacer 4", "// local edit"),
1950      );
1951
1952      stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
1953      let staged = staged_diff(&dir);
1954      assert!(staged.contains("beta changed"));
1955      assert!(!staged.contains("local edit"));
1956   }
1957
1958   #[test]
1959   fn test_stage_executable_group_noops_when_snapshot_patch_already_applied() {
1960      let dir = init_repo();
1961      write_file(&dir, "src/lib.rs", &fixture_file_original());
1962      commit_all(&dir, "initial");
1963      write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1964
1965      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1966      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1967      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1968      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1969      let group = ComposeExecutableGroup {
1970         group_id:     "G1".to_string(),
1971         commit_type:  CommitType::new("refactor").unwrap(),
1972         scope:        None,
1973         file_ids:     vec![source_file.file_id.clone()],
1974         rationale:    "all hunks".to_string(),
1975         dependencies: vec![],
1976         hunk_ids:     source_file.hunk_ids.clone(),
1977      };
1978
1979      reset_staging(dir.path().to_str().unwrap()).unwrap();
1980      let first_result =
1981         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1982      assert_eq!(first_result.result, StageResult::Staged);
1983      run_git(&dir, &["commit", "-m", "applied"]);
1984
1985      // Re-staging the same whole-file change is idempotent: `git add` restages
1986      // identical worktree content, so the index still matches HEAD afterward.
1987      let second_result =
1988         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1989      assert_eq!(second_result.result, StageResult::Staged);
1990      assert!(staged_diff(&dir).trim().is_empty());
1991   }
1992
1993   #[test]
1994   fn test_stage_executable_group_reuses_snapshot_patch_not_worktree_contents() {
1995      let dir = init_repo();
1996      write_file(&dir, "README.md", "initial\n");
1997      commit_all(&dir, "initial");
1998      write_file(&dir, "notes.txt", "planned\n");
1999
2000      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2001      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2002      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2003      let notes_file = snapshot.file_by_path("notes.txt").unwrap();
2004      let group = ComposeExecutableGroup {
2005         group_id:     "G1".to_string(),
2006         commit_type:  CommitType::new("docs").unwrap(),
2007         scope:        None,
2008         file_ids:     vec![notes_file.file_id.clone()],
2009         rationale:    "new notes".to_string(),
2010         dependencies: vec![],
2011         hunk_ids:     notes_file.hunk_ids.clone(),
2012      };
2013
2014      reset_staging(dir.path().to_str().unwrap()).unwrap();
2015      let planned_result =
2016         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2017      assert_eq!(planned_result.result, StageResult::Staged);
2018      let planned_staged = staged_diff(&dir);
2019      assert!(planned_staged.contains("+planned"));
2020      assert!(!planned_staged.contains("local edit"));
2021
2022      reset_staging(dir.path().to_str().unwrap()).unwrap();
2023      write_file(&dir, "notes.txt", "planned\nlocal edit\n");
2024      let reused_result =
2025         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2026      assert_eq!(reused_result.result, StageResult::Staged);
2027      let reused_staged = staged_diff(&dir);
2028
2029      assert_eq!(reused_staged, planned_staged);
2030      assert!(!reused_staged.contains("local edit"));
2031   }
2032
2033   #[test]
2034   fn test_stage_executable_group_materializes_new_file_from_snapshot() {
2035      let dir = init_repo();
2036      write_file(&dir, "README.md", "initial\n");
2037      commit_all(&dir, "initial");
2038
2039      let diff = r"diff --git a/notes.txt b/notes.txt
2040new file mode 100644
2041index 0000000..0000000
2042--- /dev/null
2043+++ b/notes.txt
2044@@ -1,1 +1,3 @@
2045-old
2046+old
2047+new
2048+++literal plus
2049";
2050      let stat = " notes.txt | 4 +++-\n";
2051      let snapshot = build_compose_snapshot(diff, stat).unwrap();
2052      let notes_file = snapshot.file_by_path("notes.txt").unwrap();
2053      let group = ComposeExecutableGroup {
2054         group_id:     "G1".to_string(),
2055         commit_type:  CommitType::new("docs").unwrap(),
2056         scope:        None,
2057         file_ids:     vec![notes_file.file_id.clone()],
2058         rationale:    "new notes".to_string(),
2059         dependencies: vec![],
2060         hunk_ids:     notes_file.hunk_ids.clone(),
2061      };
2062
2063      write_file(&dir, "notes.txt", "worktree edit\n");
2064      reset_staging(dir.path().to_str().unwrap()).unwrap();
2065      let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2066
2067      assert_eq!(result.result, StageResult::Staged);
2068      let staged = staged_diff(&dir);
2069      assert!(staged.contains("+old"));
2070      assert!(staged.contains("+new"));
2071      assert!(staged.contains("+++literal plus"));
2072      assert!(!staged.contains("worktree edit"));
2073      let second_result =
2074         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2075      assert_eq!(second_result.result, StageResult::AlreadyApplied);
2076   }
2077
2078   #[test]
2079   fn test_stage_executable_group_materializes_empty_new_file_from_snapshot() {
2080      let dir = init_repo();
2081      write_file(&dir, "README.md", "initial\n");
2082      commit_all(&dir, "initial");
2083
2084      let diff = r"diff --git a/empty.txt b/empty.txt
2085new file mode 100644
2086index 0000000..0000000
2087--- /dev/null
2088+++ b/empty.txt
2089";
2090      let stat = " empty.txt | 0\n";
2091      let snapshot = build_compose_snapshot(diff, stat).unwrap();
2092      let empty_file = snapshot.file_by_path("empty.txt").unwrap();
2093      let group = ComposeExecutableGroup {
2094         group_id:     "G1".to_string(),
2095         commit_type:  CommitType::new("docs").unwrap(),
2096         scope:        None,
2097         file_ids:     vec![empty_file.file_id.clone()],
2098         rationale:    "empty notes".to_string(),
2099         dependencies: vec![],
2100         hunk_ids:     empty_file.hunk_ids.clone(),
2101      };
2102
2103      write_file(&dir, "empty.txt", "worktree edit\n");
2104      reset_staging(dir.path().to_str().unwrap()).unwrap();
2105      let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2106
2107      assert_eq!(result.result, StageResult::Staged);
2108      let staged = staged_diff(&dir);
2109      assert!(staged.contains("new file mode 100644"));
2110      assert!(!staged.contains("worktree edit"));
2111   }
2112
2113   #[test]
2114   fn test_stage_executable_group_materializes_new_gitlink_from_snapshot() {
2115      let dir = init_repo();
2116      write_file(&dir, "README.md", "initial\n");
2117      commit_all(&dir, "initial");
2118
2119      let oid = "1234567890abcdef1234567890abcdef12345678";
2120      let diff = format!(
2121         "diff --git a/vendor/lib b/vendor/lib\nnew file mode 160000\nindex 0000000..{oid}\n--- \
2122          /dev/null\n+++ b/vendor/lib\n@@ -0,0 +1 @@\n+Subproject commit {oid}\n"
2123      );
2124      let stat = " vendor/lib | 1 +\n";
2125      let snapshot = build_compose_snapshot(&diff, stat).unwrap();
2126      let gitlink_file = snapshot.file_by_path("vendor/lib").unwrap();
2127      let group = ComposeExecutableGroup {
2128         group_id:     "G1".to_string(),
2129         commit_type:  CommitType::new("chore").unwrap(),
2130         scope:        None,
2131         file_ids:     vec![gitlink_file.file_id.clone()],
2132         rationale:    "add submodule".to_string(),
2133         dependencies: vec![],
2134         hunk_ids:     gitlink_file.hunk_ids.clone(),
2135      };
2136
2137      reset_staging(dir.path().to_str().unwrap()).unwrap();
2138      let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2139
2140      assert_eq!(result.result, StageResult::Staged);
2141      let staged = staged_diff(&dir);
2142      assert!(staged.contains("new file mode 160000"));
2143      assert!(staged.contains(&format!("+Subproject commit {oid}")));
2144   }
2145
2146   #[test]
2147   fn test_stage_executable_group_skips_file_whose_patch_no_longer_applies() {
2148      let dir = init_repo();
2149      write_file(&dir, "src/a.rs", &fixture_file_original());
2150      write_file(&dir, "src/b.rs", "fn b() {}\n");
2151      commit_all(&dir, "initial");
2152
2153      write_file(&dir, "src/a.rs", &fixture_file_two_hunks());
2154      write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
2155
2156      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2157      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2158      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2159      let a_file = snapshot.file_by_path("src/a.rs").unwrap();
2160      let b_file = snapshot.file_by_path("src/b.rs").unwrap();
2161      let group = ComposeExecutableGroup {
2162         group_id:     "G1".to_string(),
2163         commit_type:  CommitType::new("refactor").unwrap(),
2164         scope:        None,
2165         file_ids:     vec![a_file.file_id.clone(), b_file.file_id.clone()],
2166         rationale:    "both files".to_string(),
2167         dependencies: vec![],
2168         // Select only a's first hunk (partial) so it routes through git apply
2169         // (covers-all files are staged via git add and never "skip").
2170         hunk_ids:     std::iter::once(a_file.hunk_ids[0].clone())
2171            .chain(b_file.hunk_ids.iter().cloned())
2172            .collect(),
2173      };
2174
2175      // Diverge src/a.rs at the same lines the plan touches and commit it, so the
2176      // planned hunks for that file no longer apply (3-way merge conflicts).
2177      write_file(&dir, "src/a.rs", &fixture_file_original().replace("alpha", "alpha diverged"));
2178      run_git(&dir, &["add", "src/a.rs"]);
2179      run_git(&dir, &["commit", "-m", "diverge a"]);
2180
2181      reset_staging(dir.path().to_str().unwrap()).unwrap();
2182      write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
2183
2184      let outcome =
2185         stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2186
2187      // src/b.rs still applies, so the group is committable; src/a.rs is skipped.
2188      assert_eq!(outcome.result, StageResult::Staged);
2189      assert_eq!(outcome.skipped.len(), 1);
2190      assert_eq!(outcome.skipped[0].path, "src/a.rs");
2191
2192      let staged = staged_diff(&dir);
2193      assert!(staged.contains("b_changed"));
2194      assert!(!staged.contains("alpha changed"));
2195      // The skipped file's index entry is restored to HEAD: no conflict residue.
2196      assert!(!staged.contains("src/a.rs"));
2197   }
2198
2199   #[test]
2200   fn test_covers_all_modified_file_routes_to_git_add() {
2201      let dir = init_repo();
2202      write_file(&dir, "src/lib.rs", &fixture_file_original());
2203      commit_all(&dir, "initial");
2204      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2205
2206      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2207      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2208      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2209      let file = snapshot.file_by_path("src/lib.rs").unwrap();
2210      let group = ComposeExecutableGroup {
2211         group_id:     "G1".to_string(),
2212         commit_type:  CommitType::new("refactor").unwrap(),
2213         scope:        None,
2214         file_ids:     vec![file.file_id.clone()],
2215         rationale:    "all hunks".to_string(),
2216         dependencies: vec![],
2217         hunk_ids:     file.hunk_ids.clone(),
2218      };
2219
2220      let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
2221      // Whole-file change must be staged via git add, never via git apply.
2222      assert!(group_patch.apply_patches.is_empty());
2223      assert_eq!(group_patch.fallback_files, vec!["src/lib.rs".to_string()]);
2224   }
2225
2226   #[test]
2227   fn test_stage_executable_group_in_index_stages_crlf_file_via_git_add() {
2228      let dir = init_repo();
2229      run_git(&dir, &["config", "core.autocrlf", "false"]);
2230      let original = [
2231         "fn alpha() {",
2232         "    println!(\"alpha\");",
2233         "}",
2234         "",
2235         "// spacer 1",
2236         "// spacer 2",
2237         "// spacer 3",
2238         "// spacer 4",
2239         "fn beta() {",
2240         "    println!(\"beta\");",
2241         "}",
2242         "",
2243      ]
2244      .join("\r\n");
2245      let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2246      write_file(&dir, "src/crlf.rs", &original);
2247      commit_all(&dir, "initial");
2248      write_file(&dir, "src/crlf.rs", &modified);
2249
2250      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2251      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2252      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2253      let file = snapshot.file_by_path("src/crlf.rs").unwrap();
2254      let group = ComposeExecutableGroup {
2255         group_id:     "G1".to_string(),
2256         commit_type:  CommitType::new("fix").unwrap(),
2257         scope:        None,
2258         file_ids:     vec![file.file_id.clone()],
2259         rationale:    "crlf change".to_string(),
2260         dependencies: vec![],
2261         hunk_ids:     file.hunk_ids.clone(),
2262      };
2263
2264      let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2265      read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2266      let outcome = stage_executable_group_in_index(
2267         &snapshot,
2268         &group,
2269         dir.path().to_str().unwrap(),
2270         index.path(),
2271      )
2272      .unwrap();
2273      assert!(outcome.skipped.is_empty());
2274
2275      let staged = crate::git::git_command_with_index(index.path())
2276         .args(["show", ":src/crlf.rs"])
2277         .current_dir(dir.path())
2278         .output()
2279         .unwrap();
2280      assert!(staged.status.success());
2281      // CRLF preserved exactly, identical to the working tree.
2282      assert_eq!(String::from_utf8_lossy(&staged.stdout), modified);
2283   }
2284
2285   #[test]
2286   fn test_force_stage_splice_partial_crlf_preserves_eol() {
2287      let dir = init_repo();
2288      run_git(&dir, &["config", "core.autocrlf", "false"]);
2289      let original = [
2290         "fn alpha() {",
2291         "    println!(\"alpha\");",
2292         "}",
2293         "",
2294         "// spacer 1",
2295         "// spacer 2",
2296         "// spacer 3",
2297         "// spacer 4",
2298         "// spacer 5",
2299         "// spacer 6",
2300         "fn beta() {",
2301         "    println!(\"beta\");",
2302         "}",
2303         "",
2304      ]
2305      .join("\r\n");
2306      // Change both alpha and beta so there are two separate hunks.
2307      let modified = original
2308         .replace("println!(\"alpha\")", "println!(\"alpha changed\")")
2309         .replace("println!(\"beta\")", "println!(\"beta changed\")");
2310      write_file(&dir, "src/crlf.rs", &original);
2311      commit_all(&dir, "initial");
2312      write_file(&dir, "src/crlf.rs", &modified);
2313
2314      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2315      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2316      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2317      let file = snapshot.file_by_path("src/crlf.rs").unwrap();
2318      assert!(file.hunk_ids.len() >= 2, "need at least two hunks for a partial test");
2319
2320      // Force-stage only the FIRST hunk (alpha) -> base + alpha hunk, CRLF kept.
2321      let first_hunk = vec![file.hunk_ids[0].clone()];
2322      let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2323      read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2324      force_stage_file_from_base_in_index(
2325         &snapshot,
2326         &file.file_id,
2327         &first_hunk,
2328         dir.path().to_str().unwrap(),
2329         index.path(),
2330      )
2331      .unwrap();
2332
2333      let staged = crate::git::git_command_with_index(index.path())
2334         .args(["show", ":src/crlf.rs"])
2335         .current_dir(dir.path())
2336         .output()
2337         .unwrap();
2338      let staged = String::from_utf8_lossy(&staged.stdout).to_string();
2339      let expected = original.replace("println!(\"alpha\")", "println!(\"alpha changed\")");
2340      assert_eq!(staged, expected);
2341      // Added line carries the file's CRLF (not the diff's normalization).
2342      assert!(staged.contains("println!(\"alpha changed\");\r\n"));
2343      assert!(!staged.contains("beta changed"));
2344      assert!(staged.contains("println!(\"beta\");\r\n"));
2345      assert!(!staged.contains("\r\r"));
2346   }
2347
2348   #[test]
2349   fn test_splice_hunks_unit_lf_and_crlf() {
2350      // Direct unit test of the splicer against synthetic hunks.
2351      use crate::compose_types::ComposeHunk;
2352      fn hunk(old_start: usize, raw: &str) -> ComposeHunk {
2353         ComposeHunk {
2354            hunk_id: "H".to_string(),
2355            file_id: "F".to_string(),
2356            path: "f".to_string(),
2357            old_start,
2358            old_count: 0,
2359            new_start: 0,
2360            new_count: 0,
2361            header: String::new(),
2362            raw_patch: raw.to_string(),
2363            snippet: String::new(),
2364            semantic_key: String::new(),
2365            synthetic: false,
2366         }
2367      }
2368      // LF base, change middle line.
2369      let base = b"a\nb\nc\n";
2370      let h = hunk(1, "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n");
2371      assert_eq!(splice_hunks_into_base(base, &[&h]), b"a\nB\nc\n");
2372
2373      // CRLF base, change middle line; added line must get CRLF, no double CR.
2374      let base_cr = b"a\r\nb\r\nc\r\n";
2375      let h_cr = hunk(1, "@@ -1,3 +1,3 @@\n a\r\n-b\r\n+B\r\n c\r\n");
2376      assert_eq!(splice_hunks_into_base(base_cr, &[&h_cr]), b"a\r\nB\r\nc\r\n");
2377
2378      // No trailing newline at EOF on the new side.
2379      let base2 = b"a\nb\nc\n";
2380      let h2 = hunk(3, "@@ -3 +3 @@\n-c\n+c2\n\\ No newline at end of file\n");
2381      assert_eq!(splice_hunks_into_base(base2, &[&h2]), b"a\nb\nc2");
2382   }
2383
2384   #[test]
2385   fn test_stage_executable_group_in_index_preserves_real_staged_diff() {
2386      let dir = init_repo();
2387      write_file(&dir, "src/lib.rs", &fixture_file_original());
2388      write_file(&dir, "sentinel.txt", "base\n");
2389      commit_all(&dir, "initial");
2390      write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
2391
2392      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2393      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2394      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2395      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
2396      let group = ComposeExecutableGroup {
2397         group_id:     "G1".to_string(),
2398         commit_type:  CommitType::new("refactor").unwrap(),
2399         scope:        None,
2400         file_ids:     vec![source_file.file_id.clone()],
2401         rationale:    "source change".to_string(),
2402         dependencies: vec![],
2403         hunk_ids:     source_file.hunk_ids.clone(),
2404      };
2405
2406      write_file(&dir, "sentinel.txt", "base\nstaged sentinel\n");
2407      run_git(&dir, &["add", "sentinel.txt"]);
2408      let real_staged_before = staged_diff(&dir);
2409      assert!(real_staged_before.contains("staged sentinel"));
2410
2411      let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2412      read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2413      let outcome = stage_executable_group_in_index(
2414         &snapshot,
2415         &group,
2416         dir.path().to_str().unwrap(),
2417         index.path(),
2418      )
2419      .unwrap();
2420
2421      assert_eq!(outcome.result, StageResult::Staged);
2422      assert_eq!(staged_diff(&dir), real_staged_before);
2423      let temp_staged = staged_diff_in_index(&dir, &index);
2424      assert!(temp_staged.contains("alpha staged"));
2425      assert!(!temp_staged.contains("staged sentinel"));
2426   }
2427
2428   #[test]
2429   fn test_force_stage_file_from_base_in_index_preserves_real_staged_diff() {
2430      let dir = init_repo();
2431      run_git(&dir, &["config", "core.autocrlf", "false"]);
2432      let original = [
2433         "fn alpha() {",
2434         "    println!(\"alpha\");",
2435         "}",
2436         "",
2437         "fn beta() {",
2438         "    println!(\"beta\");",
2439         "}",
2440         "",
2441      ]
2442      .join("\r\n");
2443      let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2444      write_file(&dir, "src/crlf.rs", &original);
2445      write_file(&dir, "sentinel.txt", "base\n");
2446      commit_all(&dir, "initial");
2447      write_file(&dir, "src/crlf.rs", &modified);
2448
2449      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2450      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2451      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2452      let source_file = snapshot.file_by_path("src/crlf.rs").unwrap();
2453
2454      write_file(&dir, "sentinel.txt", "base\nstaged sentinel\n");
2455      run_git(&dir, &["add", "sentinel.txt"]);
2456      let real_staged_before = staged_diff(&dir);
2457
2458      let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2459      read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2460      force_stage_file_from_base_in_index(
2461         &snapshot,
2462         &source_file.file_id,
2463         &source_file.hunk_ids.clone(),
2464         dir.path().to_str().unwrap(),
2465         index.path(),
2466      )
2467      .unwrap();
2468
2469      assert_eq!(staged_diff(&dir), real_staged_before);
2470      let staged_blob = crate::git::git_command_with_index(index.path())
2471         .args(["show", ":src/crlf.rs"])
2472         .current_dir(dir.path())
2473         .output()
2474         .unwrap();
2475      assert!(staged_blob.status.success());
2476      assert_eq!(String::from_utf8_lossy(&staged_blob.stdout).to_string(), modified);
2477   }
2478
2479   #[test]
2480   fn test_force_stage_file_from_base_preserves_crlf_patch_lines() {
2481      let dir = init_repo();
2482      run_git(&dir, &["config", "core.autocrlf", "false"]);
2483      let original = [
2484         "fn alpha() {",
2485         "    println!(\"alpha\");",
2486         "}",
2487         "",
2488         "fn beta() {",
2489         "    println!(\"beta\");",
2490         "}",
2491         "",
2492      ]
2493      .join("\r\n");
2494      let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2495      write_file(&dir, "src/crlf.rs", &original);
2496      commit_all(&dir, "initial");
2497      write_file(&dir, "src/crlf.rs", &modified);
2498
2499      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2500      assert!(diff.contains("-    println!(\"beta\");\r\n"));
2501      assert!(diff.contains("+    println!(\"beta changed\");\r\n"));
2502      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2503      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2504      let source_file = snapshot.file_by_path("src/crlf.rs").unwrap();
2505
2506      reset_staging(dir.path().to_str().unwrap()).unwrap();
2507      force_stage_file_from_base(
2508         &snapshot,
2509         &source_file.file_id,
2510         &source_file.hunk_ids.clone(),
2511         dir.path().to_str().unwrap(),
2512      )
2513      .unwrap();
2514
2515      let staged_blob = run_git(&dir, &["show", ":src/crlf.rs"]);
2516      assert_eq!(staged_blob, modified);
2517   }
2518   #[test]
2519   fn test_force_stage_file_from_base_ignores_index_drift() {
2520      let dir = init_repo();
2521      write_file(&dir, "src/lib.rs", &fixture_file_original());
2522      commit_all(&dir, "initial");
2523      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2524
2525      let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2526      let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2527      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2528      let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
2529      assert_eq!(source_file.hunk_ids.len(), 2);
2530
2531      // Drift the index far from base: stage an unrelated full-file rewrite, so a
2532      // normal `git apply` of the planned hunks against this index would fail.
2533      write_file(&dir, "src/lib.rs", "fn totally_different() {}\n");
2534      run_git(&dir, &["add", "src/lib.rs"]);
2535
2536      // Force-stage only the first planned hunk from base, ignoring the drift.
2537      force_stage_file_from_base(
2538         &snapshot,
2539         &source_file.file_id,
2540         &[source_file.hunk_ids[0].clone()],
2541         dir.path().to_str().unwrap(),
2542      )
2543      .unwrap();
2544
2545      let staged = staged_diff(&dir);
2546      assert!(staged.contains("alpha changed"));
2547      assert!(!staged.contains("beta changed"));
2548      assert!(!staged.contains("totally_different"));
2549
2550      // Applying both hunks reconstructs the full planned target from base.
2551      force_stage_file_from_base(
2552         &snapshot,
2553         &source_file.file_id,
2554         &source_file.hunk_ids.clone(),
2555         dir.path().to_str().unwrap(),
2556      )
2557      .unwrap();
2558      let staged = staged_diff(&dir);
2559      assert!(staged.contains("alpha changed"));
2560      assert!(staged.contains("beta changed"));
2561      assert!(!staged.contains("totally_different"));
2562   }
2563
2564   #[test]
2565   fn test_force_stage_split_across_commits_leaves_worktree_clean() {
2566      let dir = init_repo();
2567      write_file(&dir, "src/lib.rs", &fixture_file_original());
2568      commit_all(&dir, "initial");
2569      // The working tree holds the full planned change and is never rewritten.
2570      write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2571
2572      let dirs = dir.path().to_str().unwrap();
2573      let diff = get_compose_diff(dirs).unwrap();
2574      let stat = get_compose_stat(dirs).unwrap();
2575      let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2576      let file = snapshot.file_by_path("src/lib.rs").unwrap();
2577      assert_eq!(file.hunk_ids.len(), 2);
2578
2579      reset_staging(dirs).unwrap();
2580
2581      // Commit 1 takes the first hunk (cumulative = [h0]).
2582      force_stage_file_from_base(&snapshot, &file.file_id, &[file.hunk_ids[0].clone()], dirs)
2583         .unwrap();
2584      run_git(&dir, &["commit", "-m", "first"]);
2585
2586      // Commit 2 takes both hunks (cumulative = [h0, h1]).
2587      force_stage_file_from_base(&snapshot, &file.file_id, &file.hunk_ids.clone(), dirs).unwrap();
2588      run_git(&dir, &["commit", "-m", "second"]);
2589
2590      // The two commits together reproduce the working tree exactly: nothing is
2591      // left uncommitted on disk and no file was modified by staging.
2592      let status = run_git(&dir, &["status", "--porcelain"]);
2593      assert!(status.trim().is_empty(), "working tree should be clean, got: {status:?}");
2594   }
2595}