Skip to main content

llm_git/
patch.rs

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