LCOV - code coverage report
Current view: top level - src - git.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 69.8 % 814 568
Test Date: 2026-02-25 04:31:59 Functions: 70.5 % 95 67

            Line data    Source code
       1              : use anyhow::{Context, Result};
       2              : use git2::{Delta, DiffOptions, Repository};
       3              : use std::path::{Path, PathBuf};
       4              : 
       5              : use crate::diff::{CommitInfo, DiffSnapshot, FileChange, Hunk};
       6              : 
       7              : #[derive(Clone)]
       8              : pub struct GitRepo {
       9              :     repo_path: PathBuf,
      10              : }
      11              : 
      12              : impl GitRepo {
      13           46 :     fn is_change_line(line: &str) -> bool {
      14           46 :         (line.starts_with('+') && !line.starts_with("+++"))
      15           15 :             || (line.starts_with('-') && !line.starts_with("---"))
      16           46 :     }
      17              : 
      18            0 :     fn hunk_line_coordinates(
      19            0 :         hunk: &Hunk,
      20            0 :         line_index: usize,
      21            0 :     ) -> Option<(Option<usize>, Option<usize>)> {
      22            0 :         if line_index >= hunk.lines.len() {
      23            0 :             return None;
      24            0 :         }
      25              : 
      26            0 :         let mut old_lineno = hunk.old_start;
      27            0 :         let mut new_lineno = hunk.new_start;
      28              : 
      29            0 :         for (idx, line) in hunk.lines.iter().enumerate() {
      30            0 :             let coords = if line.starts_with(' ') {
      31            0 :                 let current = (Some(old_lineno), Some(new_lineno));
      32            0 :                 old_lineno += 1;
      33            0 :                 new_lineno += 1;
      34            0 :                 current
      35            0 :             } else if line.starts_with('-') && !line.starts_with("---") {
      36            0 :                 let current = (Some(old_lineno), None);
      37            0 :                 old_lineno += 1;
      38            0 :                 current
      39            0 :             } else if line.starts_with('+') && !line.starts_with("+++") {
      40            0 :                 let current = (None, Some(new_lineno));
      41            0 :                 new_lineno += 1;
      42            0 :                 current
      43              :             } else {
      44            0 :                 continue;
      45              :             };
      46              : 
      47            0 :             if idx == line_index {
      48            0 :                 return Some(coords);
      49            0 :             }
      50              :         }
      51              : 
      52            0 :         None
      53            0 :     }
      54              : 
      55           15 :     fn single_line_patch_header(
      56           15 :         hunk: &Hunk,
      57           15 :         line_index: usize,
      58           15 :     ) -> Option<(usize, usize, usize, usize)> {
      59           15 :         if line_index >= hunk.lines.len() {
      60            0 :             return None;
      61           15 :         }
      62              : 
      63           15 :         let mut old_lineno = hunk.old_start;
      64           15 :         let mut new_lineno = hunk.new_start;
      65              : 
      66           63 :         for (idx, line) in hunk.lines.iter().enumerate() {
      67           63 :             if idx == line_index {
      68           15 :                 if line.starts_with('-') && !line.starts_with("---") {
      69            2 :                     return Some((old_lineno, 1, new_lineno, 0));
      70           13 :                 }
      71           13 :                 if line.starts_with('+') && !line.starts_with("+++") {
      72           13 :                     return Some((old_lineno, 0, new_lineno, 1));
      73            0 :                 }
      74            0 :                 return None;
      75           48 :             }
      76              : 
      77           48 :             if line.starts_with(' ') {
      78           27 :                 old_lineno += 1;
      79           27 :                 new_lineno += 1;
      80           27 :             } else if line.starts_with('-') && !line.starts_with("---") {
      81           13 :                 old_lineno += 1;
      82           13 :             } else if line.starts_with('+') && !line.starts_with("+++") {
      83            8 :                 new_lineno += 1;
      84            8 :             }
      85              :         }
      86              : 
      87            0 :         None
      88           15 :     }
      89              : 
      90           15 :     fn build_single_line_patch(
      91           15 :         &self,
      92           15 :         hunk: &Hunk,
      93           15 :         line_index: usize,
      94           15 :         file_path: &Path,
      95           15 :     ) -> Result<String> {
      96              :         // Verify the line exists
      97           15 :         if line_index >= hunk.lines.len() {
      98            0 :             return Err(anyhow::anyhow!("Line index out of bounds"));
      99           15 :         }
     100              : 
     101           15 :         let selected_line = &hunk.lines[line_index];
     102              : 
     103              :         // Only allow patching change lines
     104           15 :         if !Self::is_change_line(selected_line) {
     105            0 :             return Err(anyhow::anyhow!("Can only patch + or - lines"));
     106           15 :         }
     107              : 
     108           15 :         let (old_start, old_line_count, new_start, new_line_count) =
     109           15 :             Self::single_line_patch_header(hunk, line_index)
     110           15 :                 .ok_or_else(|| anyhow::anyhow!("Can only patch + or - lines"))?;
     111              : 
     112              :         // Create a proper unified diff patch
     113           15 :         let mut patch = String::new();
     114           15 :         patch.push_str(&format!(
     115           15 :             "diff --git a/{} b/{}\n",
     116           15 :             file_path.display(),
     117           15 :             file_path.display()
     118           15 :         ));
     119           15 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
     120           15 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
     121           15 :         patch.push_str(&format!(
     122           15 :             "@@ -{},{} +{},{} @@\n",
     123           15 :             old_start, old_line_count, new_start, new_line_count
     124           15 :         ));
     125              : 
     126           15 :         patch.push_str(selected_line);
     127           15 :         if !selected_line.ends_with('\n') {
     128            0 :             patch.push('\n');
     129           15 :         }
     130              : 
     131           15 :         Ok(patch)
     132           15 :     }
     133              : 
     134           15 :     fn apply_single_line_patch_raw(
     135           15 :         &self,
     136           15 :         hunk: &Hunk,
     137           15 :         line_index: usize,
     138           15 :         file_path: &Path,
     139           15 :         reverse: bool,
     140           15 :     ) -> Result<()> {
     141              :         use std::io::Write;
     142              :         use std::process::Command;
     143              : 
     144           15 :         let patch = self.build_single_line_patch(hunk, line_index, file_path)?;
     145              : 
     146           15 :         let mut cmd = Command::new("git");
     147           15 :         cmd.arg("apply")
     148           15 :             .arg("--cached")
     149           15 :             .arg("--unidiff-zero")
     150           15 :             .arg("--recount");
     151           15 :         if reverse {
     152            4 :             cmd.arg("--reverse");
     153           11 :         }
     154           15 :         cmd.arg("-")
     155           15 :             .current_dir(&self.repo_path)
     156           15 :             .stdin(std::process::Stdio::piped())
     157           15 :             .stdout(std::process::Stdio::piped())
     158           15 :             .stderr(std::process::Stdio::piped());
     159              : 
     160           15 :         let mut child = cmd.spawn()?;
     161              : 
     162           15 :         if let Some(mut stdin) = child.stdin.take() {
     163           15 :             stdin.write_all(patch.as_bytes())?;
     164            0 :         }
     165              : 
     166           15 :         let output = child.wait_with_output()?;
     167              : 
     168           15 :         if !output.status.success() {
     169            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     170            0 :             let patch_preview = if patch.len() > 500 {
     171            0 :                 format!("{}... (truncated)", &patch[..500])
     172              :             } else {
     173            0 :                 patch.clone()
     174              :             };
     175            0 :             let action = if reverse { "unstage" } else { "stage" };
     176            0 :             return Err(anyhow::anyhow!(
     177            0 :                 "Failed to {} line: {}\nPatch was:\n{}",
     178            0 :                 action,
     179            0 :                 error_msg,
     180            0 :                 patch_preview
     181            0 :             ));
     182           15 :         }
     183              : 
     184           15 :         Ok(())
     185           15 :     }
     186              : 
     187            0 :     fn is_noop_patch_apply_error(err: &anyhow::Error) -> bool {
     188            0 :         let msg = err.to_string().to_lowercase();
     189            0 :         msg.contains("patch does not apply") || msg.contains("no valid patches in input")
     190            0 :     }
     191              : 
     192            3 :     fn change_line_indices(hunk: &Hunk) -> std::collections::HashSet<usize> {
     193            3 :         hunk.lines
     194            3 :             .iter()
     195            3 :             .enumerate()
     196           16 :             .filter_map(|(idx, line)| Self::is_change_line(line).then_some(idx))
     197            3 :             .collect()
     198            3 :     }
     199              : 
     200            0 :     fn sort_indices_desc_by_position(
     201            0 :         hunk: &Hunk,
     202            0 :         indices: &std::collections::HashSet<usize>,
     203            0 :     ) -> Vec<usize> {
     204            0 :         let mut ordered: Vec<usize> = indices.iter().copied().collect();
     205            0 :         ordered.sort_by(|a, b| {
     206            0 :             let a_pos = Self::hunk_line_coordinates(hunk, *a)
     207            0 :                 .map(|(old, new)| old.or(new).unwrap_or(0))
     208            0 :                 .unwrap_or(0);
     209            0 :             let b_pos = Self::hunk_line_coordinates(hunk, *b)
     210            0 :                 .map(|(old, new)| old.or(new).unwrap_or(0))
     211            0 :                 .unwrap_or(0);
     212            0 :             b_pos.cmp(&a_pos).then_with(|| b.cmp(a))
     213            0 :         });
     214            0 :         ordered
     215            0 :     }
     216              : 
     217            3 :     fn unstaged_hunk_count_for_file(&self, file_path: &Path) -> Result<usize> {
     218            3 :         let repo = Repository::open(&self.repo_path)?;
     219            3 :         let index = repo.index()?;
     220              : 
     221            3 :         let mut diff_opts = DiffOptions::new();
     222            3 :         diff_opts.pathspec(file_path);
     223            3 :         diff_opts.context_lines(3);
     224              : 
     225            3 :         let diff = repo.diff_index_to_workdir(Some(&index), Some(&mut diff_opts))?;
     226            3 :         let mut count: usize = 0;
     227            3 :         diff.foreach(
     228              :             &mut |_, _| true,
     229            3 :             None,
     230            3 :             Some(&mut |_, _| {
     231            3 :                 count += 1;
     232            3 :                 true
     233            3 :             }),
     234            3 :             None,
     235            0 :         )?;
     236              : 
     237            3 :         Ok(count)
     238            3 :     }
     239              : 
     240              :     #[allow(dead_code)]
     241            0 :     fn set_hunk_staged_lines_with_reset(
     242            0 :         &self,
     243            0 :         hunk: &Hunk,
     244            0 :         file_path: &Path,
     245            0 :         desired_staged_indices: &std::collections::HashSet<usize>,
     246            0 :         reset_indices: &std::collections::HashSet<usize>,
     247            0 :     ) -> Result<()> {
     248              :         use std::collections::HashSet;
     249              : 
     250            0 :         let all_change_indices = Self::change_line_indices(hunk);
     251            0 :         let desired: HashSet<usize> = desired_staged_indices
     252            0 :             .intersection(&all_change_indices)
     253            0 :             .copied()
     254            0 :             .collect();
     255              : 
     256            0 :         let reset_filtered: HashSet<usize> = reset_indices
     257            0 :             .intersection(&all_change_indices)
     258            0 :             .copied()
     259            0 :             .collect();
     260              : 
     261            0 :         crate::logger::debug(format!(
     262              :             "set_hunk_staged_lines_with_reset file={} old_start={} new_start={} all_changes={} desired={} reset={}",
     263            0 :             file_path.display(),
     264              :             hunk.old_start,
     265              :             hunk.new_start,
     266            0 :             all_change_indices.len(),
     267            0 :             desired.len(),
     268            0 :             reset_indices.len()
     269              :         ));
     270              : 
     271              :         // Fast path for whole-hunk unstage requests.
     272            0 :         if desired.is_empty() && reset_filtered.len() == all_change_indices.len() {
     273            0 :             if let Err(full_unstage_err) = self.unstage_hunk(hunk, file_path) {
     274            0 :                 crate::logger::debug(format!(
     275              :                     "full hunk unstage failed; aborting without fallback file={}: {}",
     276            0 :                     file_path.display(),
     277              :                     full_unstage_err
     278              :                 ));
     279            0 :                 return Err(anyhow::anyhow!(
     280            0 :                     "Failed to unstage hunk in {}",
     281            0 :                     file_path.display()
     282            0 :                 ));
     283              :             } else {
     284            0 :                 return Ok(());
     285              :             }
     286            0 :         }
     287              : 
     288              :         // Reset staged state for requested lines and tolerate no-op reverse-patch failures.
     289            0 :         let reset_order = Self::sort_indices_desc_by_position(hunk, &reset_filtered);
     290            0 :         for idx in reset_order {
     291            0 :             if let Err(e) = self.apply_single_line_patch_raw(hunk, idx, file_path, true) {
     292            0 :                 if !Self::is_noop_patch_apply_error(&e) {
     293            0 :                     crate::logger::warn(format!(
     294              :                         "reset reverse-apply failed at idx={} file={}: {}",
     295              :                         idx,
     296            0 :                         file_path.display(),
     297              :                         e
     298              :                     ));
     299            0 :                     return Err(e);
     300            0 :                 }
     301            0 :                 crate::logger::trace(format!(
     302              :                     "reset reverse-apply no-op at idx={} file={}",
     303              :                     idx,
     304            0 :                     file_path.display()
     305              :                 ));
     306            0 :             }
     307              :         }
     308              : 
     309            0 :         if desired.is_empty() {
     310            0 :             return Ok(());
     311            0 :         }
     312              : 
     313            0 :         if desired.len() == all_change_indices.len() {
     314              :             // Prefer atomic hunk stage; avoid per-line fallback for full-hunk requests
     315              :             // because it can drift/duplicate in partial-index states.
     316            0 :             if let Err(stage_err) = self.stage_hunk(hunk, file_path) {
     317            0 :                 crate::logger::debug(format!(
     318              :                     "full hunk stage failed in set_hunk_staged_lines_with_reset; aborting without fallback file={}: {}",
     319            0 :                     file_path.display(),
     320              :                     stage_err
     321              :                 ));
     322            0 :                 return Err(anyhow::anyhow!(
     323            0 :                     "Failed to fully stage hunk in {}",
     324            0 :                     file_path.display()
     325            0 :                 ));
     326            0 :             }
     327              : 
     328            0 :             return Ok(());
     329            0 :         }
     330              : 
     331            0 :         let stage_order = Self::sort_indices_desc_by_position(hunk, &desired);
     332            0 :         for idx in stage_order {
     333            0 :             self.apply_single_line_patch_raw(hunk, idx, file_path, false)?;
     334              :         }
     335              : 
     336            0 :         Ok(())
     337            0 :     }
     338              : 
     339              :     #[allow(dead_code)]
     340            0 :     fn set_hunk_staged_lines(
     341            0 :         &self,
     342            0 :         hunk: &Hunk,
     343            0 :         file_path: &Path,
     344            0 :         desired_staged_indices: &std::collections::HashSet<usize>,
     345            0 :     ) -> Result<()> {
     346            0 :         let currently_staged = self.detect_staged_lines(hunk, file_path)?;
     347            0 :         self.set_hunk_staged_lines_with_reset(
     348            0 :             hunk,
     349            0 :             file_path,
     350            0 :             desired_staged_indices,
     351            0 :             &currently_staged,
     352              :         )
     353            0 :     }
     354              : 
     355            3 :     pub fn toggle_hunk_staging(&self, hunk: &Hunk, file_path: &Path) -> Result<bool> {
     356              :         // Returns true if final state is staged, false if final state is unstaged.
     357            3 :         let currently_staged = self.detect_staged_lines(hunk, file_path)?;
     358            3 :         let all_change_indices = Self::change_line_indices(hunk);
     359              : 
     360              :         // Hunk-mode `s` behavior:
     361              :         // - fully staged hunk => unstage hunk
     362              :         // - partially/unstaged hunk => stage remaining lines
     363            3 :         if currently_staged.len() < all_change_indices.len() {
     364              :             // Partial hunk: safely stage remaining unstaged changes only when this
     365              :             // file has a single unstaged hunk (equivalent to "stage the rest").
     366            3 :             if self.unstaged_hunk_count_for_file(file_path)? == 1 {
     367            3 :                 self.stage_file(file_path)?;
     368            3 :                 return Ok(true);
     369            0 :             }
     370              : 
     371              :             // Multiple unstaged hunks in file: best-effort stage only the remaining
     372              :             // lines in the selected hunk. Use both detected staged lines and UI hints
     373              :             // to avoid reapplying already-staged lines.
     374            0 :             let mut staged_known = currently_staged.clone();
     375            0 :             for idx in &hunk.staged_line_indices {
     376            0 :                 if all_change_indices.contains(idx) {
     377            0 :                     staged_known.insert(*idx);
     378            0 :                 }
     379              :             }
     380              : 
     381            0 :             let mut remaining: std::collections::HashSet<usize> = all_change_indices
     382            0 :                 .difference(&staged_known)
     383            0 :                 .copied()
     384            0 :                 .collect();
     385              : 
     386              :             // Run up to two passes to absorb coordinate drift after first apply set.
     387            0 :             for _ in 0..2 {
     388            0 :                 if remaining.is_empty() {
     389            0 :                     return Ok(true);
     390            0 :                 }
     391              : 
     392            0 :                 let stage_order = Self::sort_indices_desc_by_position(hunk, &remaining);
     393            0 :                 let mut progress = false;
     394            0 :                 for idx in stage_order {
     395            0 :                     match self.apply_single_line_patch_raw(hunk, idx, file_path, false) {
     396            0 :                         Ok(_) => progress = true,
     397            0 :                         Err(e) if Self::is_noop_patch_apply_error(&e) => {
     398            0 :                             crate::logger::trace(format!(
     399            0 :                                 "toggle stage remaining no-op at idx={} file={}",
     400            0 :                                 idx,
     401            0 :                                 file_path.display()
     402            0 :                             ));
     403            0 :                         }
     404            0 :                         Err(e) => return Err(e),
     405              :                     }
     406              :                 }
     407              : 
     408            0 :                 let staged_after = self.detect_staged_lines(hunk, file_path)?;
     409            0 :                 remaining = all_change_indices
     410            0 :                     .difference(&staged_after)
     411            0 :                     .copied()
     412            0 :                     .collect();
     413              : 
     414            0 :                 if !progress {
     415            0 :                     break;
     416            0 :                 }
     417              :             }
     418              : 
     419            0 :             return Err(anyhow::anyhow!(
     420            0 :                 "Cannot safely complete partial hunk staging in {} (multiple unstaged hunks)",
     421            0 :                 file_path.display()
     422            0 :             ));
     423            0 :         }
     424              : 
     425              :         // Fully staged hunk => unstage full hunk.
     426            0 :         match self.unstage_hunk(hunk, file_path) {
     427            0 :             Ok(_) => Ok(false),
     428            0 :             Err(e) => {
     429            0 :                 crate::logger::debug(format!(
     430              :                     "full hunk unstage failed in toggle; aborting without fallback file={}: {}",
     431            0 :                     file_path.display(),
     432              :                     e
     433              :                 ));
     434            0 :                 Err(anyhow::anyhow!(
     435            0 :                     "Failed to unstage hunk in {}",
     436            0 :                     file_path.display()
     437            0 :                 ))
     438              :             }
     439              :         }
     440            3 :     }
     441              : 
     442           61 :     pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
     443           61 :         let repo_path = Repository::discover(path.as_ref())
     444           61 :             .context("Failed to find git repository")?
     445           59 :             .workdir()
     446           59 :             .context("Repository has no working directory")?
     447           59 :             .to_path_buf();
     448              : 
     449           59 :         Ok(Self { repo_path })
     450           61 :     }
     451              : 
     452           29 :     pub fn repo_path(&self) -> &Path {
     453           29 :         &self.repo_path
     454           29 :     }
     455              : 
     456              :     /// Run `git commit` interactively, allowing Git to launch the configured editor.
     457            2 :     pub fn commit_with_editor(&self) -> Result<std::process::ExitStatus> {
     458              :         use std::process::Command;
     459              : 
     460            2 :         let status = Command::new("git")
     461            2 :             .arg("commit")
     462            2 :             .current_dir(&self.repo_path)
     463            2 :             .status()
     464            2 :             .context("Failed to run `git commit`")?;
     465              : 
     466            2 :         Ok(status)
     467            2 :     }
     468              : 
     469              :     /// Get a list of recent commits (up to `count`) for the commit review picker.
     470           17 :     pub fn get_recent_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
     471           17 :         let repo = Repository::open(&self.repo_path)?;
     472           17 :         let mut revwalk = repo.revwalk()?;
     473           17 :         revwalk.push_head().context("No commits found")?;
     474           17 :         revwalk.set_sorting(git2::Sort::TIME)?;
     475              : 
     476           17 :         let mut commits = Vec::new();
     477           34 :         for oid in revwalk.take(count) {
     478           34 :             let oid = oid?;
     479           34 :             let commit = repo.find_commit(oid)?;
     480           34 :             let sha = oid.to_string();
     481           34 :             let short_sha = sha[..7.min(sha.len())].to_string();
     482           34 :             let summary = commit.summary().unwrap_or("").to_string();
     483           34 :             let author = commit.author().name().unwrap_or("unknown").to_string();
     484           34 :             commits.push(CommitInfo {
     485           34 :                 sha,
     486           34 :                 short_sha,
     487           34 :                 summary,
     488           34 :                 author,
     489           34 :             });
     490              :         }
     491              : 
     492           17 :         Ok(commits)
     493           17 :     }
     494              : 
     495              :     /// Get a DiffSnapshot for a specific commit (diff between commit's parent and the commit).
     496           10 :     pub fn get_commit_diff(&self, commit_sha: &str) -> Result<DiffSnapshot> {
     497           10 :         let repo = Repository::open(&self.repo_path)?;
     498           10 :         let oid = git2::Oid::from_str(commit_sha).context("Invalid commit SHA")?;
     499           10 :         let commit = repo.find_commit(oid)?;
     500           10 :         let commit_tree = commit.tree()?;
     501              : 
     502           10 :         let parent_tree = if commit.parent_count() > 0 {
     503            8 :             Some(commit.parent(0)?.tree()?)
     504              :         } else {
     505            2 :             None
     506              :         };
     507              : 
     508           10 :         let mut diff_opts = DiffOptions::new();
     509           10 :         diff_opts.context_lines(3);
     510              : 
     511           10 :         let diff = repo.diff_tree_to_tree(
     512           10 :             parent_tree.as_ref(),
     513           10 :             Some(&commit_tree),
     514           10 :             Some(&mut diff_opts),
     515            0 :         )?;
     516              : 
     517           10 :         let mut files = Vec::new();
     518              : 
     519              :         // Collect file paths first
     520           10 :         diff.foreach(
     521           10 :             &mut |delta, _progress| {
     522           10 :                 let file_path = match delta.status() {
     523              :                     Delta::Added | Delta::Modified | Delta::Deleted => {
     524           10 :                         delta.new_file().path().or_else(|| delta.old_file().path())
     525              :                     }
     526            0 :                     _ => None,
     527              :                 };
     528              : 
     529           10 :                 if let Some(path) = file_path {
     530           10 :                     files.push(FileChange {
     531           10 :                         path: path.to_path_buf(),
     532           10 :                         status: format!("{:?}", delta.status()),
     533           10 :                         hunks: Vec::new(),
     534           10 :                     });
     535           10 :                 }
     536           10 :                 true
     537           10 :             },
     538           10 :             None,
     539           10 :             None,
     540           10 :             None,
     541            0 :         )?;
     542              : 
     543              :         // Get hunks for each file from the commit diff
     544           10 :         for file in &mut files {
     545           10 :             if let Ok(hunks) =
     546           10 :                 self.get_commit_file_hunks(&repo, &file.path, parent_tree.as_ref(), &commit_tree)
     547           10 :             {
     548           10 :                 file.hunks = hunks;
     549           10 :             }
     550              :         }
     551              : 
     552           10 :         Ok(DiffSnapshot {
     553           10 :             timestamp: std::time::SystemTime::now(),
     554           10 :             files,
     555           10 :         })
     556           10 :     }
     557              : 
     558              :     /// Get hunks for a file from a commit diff (parent tree vs commit tree).
     559           10 :     fn get_commit_file_hunks(
     560           10 :         &self,
     561           10 :         repo: &Repository,
     562           10 :         path: &Path,
     563           10 :         parent_tree: Option<&git2::Tree>,
     564           10 :         commit_tree: &git2::Tree,
     565           10 :     ) -> Result<Vec<Hunk>> {
     566           10 :         let mut diff_opts = DiffOptions::new();
     567           10 :         diff_opts.pathspec(path);
     568           10 :         diff_opts.context_lines(3);
     569              : 
     570           10 :         let diff = repo.diff_tree_to_tree(parent_tree, Some(commit_tree), Some(&mut diff_opts))?;
     571              : 
     572           10 :         let path_buf = path.to_path_buf();
     573              : 
     574              :         use std::cell::RefCell;
     575              :         use std::rc::Rc;
     576              : 
     577           10 :         let hunks = Rc::new(RefCell::new(Vec::new()));
     578           10 :         let current_hunk_lines = Rc::new(RefCell::new(Vec::new()));
     579           10 :         let current_old_start = Rc::new(RefCell::new(0usize));
     580           10 :         let current_new_start = Rc::new(RefCell::new(0usize));
     581           10 :         let in_hunk = Rc::new(RefCell::new(false));
     582              : 
     583           10 :         let hunks_clone = hunks.clone();
     584           10 :         let lines_clone = current_hunk_lines.clone();
     585           10 :         let old_clone = current_old_start.clone();
     586           10 :         let new_clone = current_new_start.clone();
     587           10 :         let in_hunk_clone = in_hunk.clone();
     588           10 :         let path_clone = path_buf.clone();
     589              : 
     590           10 :         let lines_clone2 = current_hunk_lines.clone();
     591           10 :         let in_hunk_clone2 = in_hunk.clone();
     592              : 
     593           10 :         diff.foreach(
     594              :             &mut |_, _| true,
     595           10 :             None,
     596           10 :             Some(&mut move |_, hunk| {
     597           10 :                 if *in_hunk_clone.borrow() && !lines_clone.borrow().is_empty() {
     598            0 :                     hunks_clone.borrow_mut().push(Hunk::new(
     599            0 :                         *old_clone.borrow(),
     600            0 :                         *new_clone.borrow(),
     601            0 :                         lines_clone.borrow().clone(),
     602            0 :                         &path_clone,
     603            0 :                     ));
     604            0 :                     lines_clone.borrow_mut().clear();
     605           10 :                 }
     606           10 :                 *old_clone.borrow_mut() = hunk.old_start() as usize;
     607           10 :                 *new_clone.borrow_mut() = hunk.new_start() as usize;
     608           10 :                 *in_hunk_clone.borrow_mut() = true;
     609           10 :                 true
     610           10 :             }),
     611           20 :             Some(&mut move |_, _, line| {
     612           20 :                 if *in_hunk_clone2.borrow() {
     613           20 :                     let content = String::from_utf8_lossy(line.content()).to_string();
     614           20 :                     lines_clone2
     615           20 :                         .borrow_mut()
     616           20 :                         .push(format!("{}{}", line.origin(), content));
     617           20 :                 }
     618           20 :                 true
     619           20 :             }),
     620            0 :         )?;
     621              : 
     622           10 :         if *in_hunk.borrow() && !current_hunk_lines.borrow().is_empty() {
     623           10 :             hunks.borrow_mut().push(Hunk::new(
     624           10 :                 *current_old_start.borrow(),
     625           10 :                 *current_new_start.borrow(),
     626           10 :                 current_hunk_lines.borrow().clone(),
     627           10 :                 &path_buf,
     628           10 :             ));
     629           10 :         }
     630              : 
     631           10 :         let result = hunks.borrow().clone();
     632           10 :         Ok(result)
     633           10 :     }
     634              : 
     635           57 :     pub fn get_diff_snapshot(&self) -> Result<DiffSnapshot> {
     636           57 :         let repo = Repository::open(&self.repo_path)?;
     637              : 
     638              :         // Get the diff between HEAD and working directory (includes both staged and unstaged)
     639           57 :         let mut diff_opts = DiffOptions::new();
     640           57 :         diff_opts.include_untracked(true);
     641           57 :         diff_opts.recurse_untracked_dirs(true);
     642              : 
     643              :         // Get HEAD tree (handle empty repo case)
     644           57 :         let head_tree = match repo.head() {
     645           45 :             Ok(head) => head.peel_to_tree().ok(),
     646           12 :             Err(_) => None,
     647              :         };
     648              : 
     649              :         // This shows all changes from HEAD to workdir (both staged and unstaged)
     650           57 :         let diff =
     651           57 :             repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))?;
     652              : 
     653           57 :         let mut files = Vec::new();
     654              : 
     655           57 :         diff.foreach(
     656           37 :             &mut |delta, _progress| {
     657           37 :                 let file_path = match delta.status() {
     658              :                     Delta::Added | Delta::Modified | Delta::Deleted => {
     659           37 :                         delta.new_file().path().or_else(|| delta.old_file().path())
     660              :                     }
     661            0 :                     _ => None,
     662              :                 };
     663              : 
     664           37 :                 if let Some(path) = file_path {
     665           37 :                     files.push(FileChange {
     666           37 :                         path: path.to_path_buf(),
     667           37 :                         status: format!("{:?}", delta.status()),
     668           37 :                         hunks: Vec::new(),
     669           37 :                     });
     670           37 :                 }
     671           37 :                 true
     672           37 :             },
     673           57 :             None,
     674           57 :             None,
     675           57 :             None,
     676            0 :         )?;
     677              : 
     678              :         // Now get the actual diff content for each file
     679           57 :         for file in &mut files {
     680           37 :             if let Ok(hunks) = self.get_file_hunks(&repo, &file.path) {
     681           37 :                 file.hunks = hunks;
     682           37 :             }
     683              :         }
     684              : 
     685           57 :         Ok(DiffSnapshot {
     686           57 :             timestamp: std::time::SystemTime::now(),
     687           57 :             files,
     688           57 :         })
     689           57 :     }
     690              : 
     691           37 :     fn get_file_hunks(&self, repo: &Repository, path: &Path) -> Result<Vec<Hunk>> {
     692           37 :         let mut diff_opts = DiffOptions::new();
     693           37 :         diff_opts.pathspec(path);
     694           37 :         diff_opts.context_lines(3);
     695              : 
     696              :         // Get HEAD tree (handle empty repo case)
     697           37 :         let head_tree = match repo.head() {
     698           37 :             Ok(head) => head.peel_to_tree().ok(),
     699            0 :             Err(_) => None,
     700              :         };
     701              : 
     702              :         // Get diff from HEAD to workdir (includes both staged and unstaged)
     703           37 :         let diff =
     704           37 :             repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))?;
     705              : 
     706           37 :         let path_buf = path.to_path_buf();
     707              : 
     708              :         use std::cell::RefCell;
     709              :         use std::rc::Rc;
     710              : 
     711           37 :         let hunks = Rc::new(RefCell::new(Vec::new()));
     712           37 :         let current_hunk_lines = Rc::new(RefCell::new(Vec::new()));
     713           37 :         let current_old_start = Rc::new(RefCell::new(0usize));
     714           37 :         let current_new_start = Rc::new(RefCell::new(0usize));
     715           37 :         let in_hunk = Rc::new(RefCell::new(false));
     716              : 
     717           37 :         let hunks_clone = hunks.clone();
     718           37 :         let lines_clone = current_hunk_lines.clone();
     719           37 :         let old_clone = current_old_start.clone();
     720           37 :         let new_clone = current_new_start.clone();
     721           37 :         let in_hunk_clone = in_hunk.clone();
     722           37 :         let path_clone = path_buf.clone();
     723              : 
     724           37 :         let lines_clone2 = current_hunk_lines.clone();
     725           37 :         let in_hunk_clone2 = in_hunk.clone();
     726              : 
     727           37 :         diff.foreach(
     728              :             &mut |_, _| true,
     729           37 :             None,
     730           37 :             Some(&mut move |_, hunk| {
     731              :                 // Save previous hunk if exists
     732           37 :                 if *in_hunk_clone.borrow() && !lines_clone.borrow().is_empty() {
     733            0 :                     hunks_clone.borrow_mut().push(Hunk::new(
     734            0 :                         *old_clone.borrow(),
     735            0 :                         *new_clone.borrow(),
     736            0 :                         lines_clone.borrow().clone(),
     737            0 :                         &path_clone,
     738            0 :                     ));
     739            0 :                     lines_clone.borrow_mut().clear();
     740           37 :                 }
     741              : 
     742              :                 // Start new hunk
     743           37 :                 *old_clone.borrow_mut() = hunk.old_start() as usize;
     744           37 :                 *new_clone.borrow_mut() = hunk.new_start() as usize;
     745           37 :                 *in_hunk_clone.borrow_mut() = true;
     746           37 :                 true
     747           37 :             }),
     748          171 :             Some(&mut move |_, _, line| {
     749              :                 // Add line to current hunk
     750          171 :                 if *in_hunk_clone2.borrow() {
     751          171 :                     let content = String::from_utf8_lossy(line.content()).to_string();
     752          171 :                     lines_clone2
     753          171 :                         .borrow_mut()
     754          171 :                         .push(format!("{}{}", line.origin(), content));
     755          171 :                 }
     756          171 :                 true
     757          171 :             }),
     758            0 :         )?;
     759              : 
     760              :         // Don't forget the last hunk
     761           37 :         if *in_hunk.borrow() && !current_hunk_lines.borrow().is_empty() {
     762           37 :             hunks.borrow_mut().push(Hunk::new(
     763           37 :                 *current_old_start.borrow(),
     764           37 :                 *current_new_start.borrow(),
     765           37 :                 current_hunk_lines.borrow().clone(),
     766           37 :                 &path_buf,
     767           37 :             ));
     768           37 :         }
     769              : 
     770              :         // Extract the hunks - clone to avoid lifetime issues
     771           37 :         let result = hunks.borrow().clone();
     772           37 :         Ok(result)
     773           37 :     }
     774              : 
     775              :     /// Stage an entire file
     776            6 :     pub fn stage_file(&self, file_path: &Path) -> Result<()> {
     777            6 :         let repo = Repository::open(&self.repo_path)?;
     778            6 :         let mut index = repo.index()?;
     779            6 :         index.add_path(file_path)?;
     780            6 :         index.write()?;
     781            6 :         Ok(())
     782            6 :     }
     783              : 
     784              :     /// Stage a specific hunk by applying it as a patch
     785            2 :     pub fn stage_hunk(&self, hunk: &Hunk, file_path: &Path) -> Result<()> {
     786              :         use std::io::Write;
     787              :         use std::process::Command;
     788              : 
     789              :         // Create a proper unified diff patch
     790            2 :         let mut patch = String::new();
     791              : 
     792              :         // Diff header
     793            2 :         patch.push_str(&format!(
     794            2 :             "diff --git a/{} b/{}\n",
     795            2 :             file_path.display(),
     796            2 :             file_path.display()
     797            2 :         ));
     798            2 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
     799            2 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
     800              : 
     801              :         // Count actual add/remove lines for the hunk header
     802            2 :         let mut old_lines = 0;
     803            2 :         let mut new_lines = 0;
     804            8 :         for line in &hunk.lines {
     805            8 :             if line.starts_with('-') && !line.starts_with("---") {
     806            2 :                 old_lines += 1;
     807            6 :             } else if line.starts_with('+') && !line.starts_with("+++") {
     808            2 :                 new_lines += 1;
     809            4 :             } else if line.starts_with(' ') {
     810            4 :                 old_lines += 1;
     811            4 :                 new_lines += 1;
     812            4 :             }
     813              :         }
     814              : 
     815              :         // Hunk header
     816            2 :         patch.push_str(&format!(
     817            2 :             "@@ -{},{} +{},{} @@\n",
     818            2 :             hunk.old_start, old_lines, hunk.new_start, new_lines
     819            2 :         ));
     820              : 
     821              :         // Hunk content
     822            8 :         for line in &hunk.lines {
     823            8 :             patch.push_str(line);
     824            8 :             if !line.ends_with('\n') {
     825            0 :                 patch.push('\n');
     826            8 :             }
     827              :         }
     828              : 
     829              :         // Use git apply to stage the hunk
     830            2 :         let mut child = Command::new("git")
     831            2 :             .arg("apply")
     832            2 :             .arg("--cached")
     833            2 :             .arg("--unidiff-zero")
     834            2 :             .arg("-")
     835            2 :             .current_dir(&self.repo_path)
     836            2 :             .stdin(std::process::Stdio::piped())
     837            2 :             .stdout(std::process::Stdio::piped())
     838            2 :             .stderr(std::process::Stdio::piped())
     839            2 :             .spawn()?;
     840              : 
     841            2 :         if let Some(mut stdin) = child.stdin.take() {
     842            2 :             stdin.write_all(patch.as_bytes())?;
     843            0 :         }
     844              : 
     845            2 :         let output = child.wait_with_output()?;
     846              : 
     847            2 :         if !output.status.success() {
     848            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     849            0 :             return Err(anyhow::anyhow!("Failed to stage hunk: {}", error_msg));
     850            2 :         }
     851              : 
     852            2 :         Ok(())
     853            2 :     }
     854              : 
     855              :     /// Detect which lines in a hunk are currently staged in the index
     856              :     /// Returns a HashSet of line indices that are staged
     857           51 :     pub fn detect_staged_lines(
     858           51 :         &self,
     859           51 :         hunk: &Hunk,
     860           51 :         file_path: &Path,
     861           51 :     ) -> Result<std::collections::HashSet<usize>> {
     862              :         use std::collections::HashSet;
     863              : 
     864           51 :         let repo = Repository::open(&self.repo_path)?;
     865              : 
     866              :         // Get diff from HEAD to index (only staged changes)
     867           51 :         let head_tree = match repo.head() {
     868           51 :             Ok(head) => head.peel_to_tree().ok(),
     869            0 :             Err(_) => None,
     870              :         };
     871              : 
     872           51 :         let mut diff_opts = DiffOptions::new();
     873           51 :         diff_opts.pathspec(file_path);
     874              : 
     875           51 :         let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?;
     876              : 
     877           51 :         let index = repo.index()?;
     878           51 :         let mut unstaged_opts = DiffOptions::new();
     879           51 :         unstaged_opts.pathspec(file_path);
     880           51 :         let unstaged_diff = repo.diff_index_to_workdir(Some(&index), Some(&mut unstaged_opts))?;
     881              : 
     882           51 :         let mut staged_lines = HashSet::new();
     883              : 
     884              :         // Track exact staged deletions by (HEAD old line number, content_without_prefix).
     885              :         // For additions we prefer deriving from unstaged additions (index->worktree) because
     886              :         // HEAD->index new line numbers can diverge from HEAD->worktree when unstaged changes
     887              :         // exist earlier in the same hunk.
     888           51 :         let mut staged_deletions: HashSet<(usize, String)> = HashSet::new();
     889           51 :         let mut unstaged_additions: HashSet<(usize, String)> = HashSet::new();
     890              : 
     891           51 :         diff.foreach(
     892              :             &mut |_, _| true,
     893           51 :             None,
     894           51 :             None,
     895          160 :             Some(&mut |_, _, line| {
     896          160 :                 let content = String::from_utf8_lossy(line.content())
     897          160 :                     .trim_end_matches('\n')
     898          160 :                     .to_string();
     899              : 
     900          160 :                 if line.origin() == '-' {
     901           16 :                     if let Some(old_lineno) = line.old_lineno() {
     902           16 :                         staged_deletions.insert((old_lineno as usize, content));
     903           16 :                     }
     904          144 :                 }
     905          160 :                 true
     906          160 :             }),
     907            0 :         )?;
     908              : 
     909           51 :         unstaged_diff.foreach(
     910              :             &mut |_, _| true,
     911           51 :             None,
     912           51 :             None,
     913          207 :             Some(&mut |_, _, line| {
     914          207 :                 if line.origin() == '+' {
     915           41 :                     let content = String::from_utf8_lossy(line.content())
     916           41 :                         .trim_end_matches('\n')
     917           41 :                         .to_string();
     918           41 :                     if let Some(new_lineno) = line.new_lineno() {
     919           41 :                         unstaged_additions.insert((new_lineno as usize, content));
     920           41 :                     }
     921          166 :                 }
     922          207 :                 true
     923          207 :             }),
     924            0 :         )?;
     925              : 
     926              :         // Walk the target hunk and compute exact old/new coordinates for each line,
     927              :         // then check whether that exact change exists in the staged index diff.
     928           51 :         let mut old_lineno = hunk.old_start;
     929           51 :         let mut new_lineno = hunk.new_start;
     930              : 
     931          257 :         for (hunk_idx, hunk_line) in hunk.lines.iter().enumerate() {
     932          257 :             if hunk_line.starts_with(' ') {
     933          125 :                 old_lineno += 1;
     934          125 :                 new_lineno += 1;
     935          132 :             } else if hunk_line.starts_with('-') && !hunk_line.starts_with("---") {
     936           55 :                 let content = hunk_line[1..].trim_end_matches('\n').to_string();
     937           55 :                 if staged_deletions.contains(&(old_lineno, content)) {
     938           16 :                     staged_lines.insert(hunk_idx);
     939           39 :                 }
     940           55 :                 old_lineno += 1;
     941           77 :             } else if hunk_line.starts_with('+') && !hunk_line.starts_with("+++") {
     942           77 :                 let content = hunk_line[1..].trim_end_matches('\n').to_string();
     943              :                 // '+' line is staged if it is NOT present as an unstaged worktree addition.
     944           77 :                 if !unstaged_additions.contains(&(new_lineno, content)) {
     945           38 :                     staged_lines.insert(hunk_idx);
     946           39 :                 }
     947           77 :                 new_lineno += 1;
     948            0 :             }
     949              :         }
     950              : 
     951           51 :         Ok(staged_lines)
     952           51 :     }
     953              : 
     954              :     /// Stage a single line from a hunk
     955           11 :     pub fn stage_single_line(
     956           11 :         &self,
     957           11 :         hunk: &Hunk,
     958           11 :         line_index: usize,
     959           11 :         file_path: &Path,
     960           11 :     ) -> Result<()> {
     961           11 :         let selected_line = hunk
     962           11 :             .lines
     963           11 :             .get(line_index)
     964           11 :             .ok_or_else(|| anyhow::anyhow!("Line index out of bounds"))?;
     965           11 :         if !Self::is_change_line(selected_line) {
     966            0 :             return Err(anyhow::anyhow!("Can only stage + or - lines"));
     967           11 :         }
     968              : 
     969           11 :         let currently_staged = self.detect_staged_lines(hunk, file_path)?;
     970           11 :         if currently_staged.contains(&line_index) {
     971            0 :             crate::logger::trace(format!(
     972              :                 "stage_single_line no-op already staged file={} line_index={}",
     973            0 :                 file_path.display(),
     974              :                 line_index
     975              :             ));
     976            0 :             return Ok(());
     977           11 :         }
     978              : 
     979           11 :         crate::logger::debug(format!(
     980              :             "stage_single_line file={} line_index={}",
     981           11 :             file_path.display(),
     982              :             line_index
     983              :         ));
     984              : 
     985           11 :         self.apply_single_line_patch_raw(hunk, line_index, file_path, false)?;
     986              : 
     987           11 :         let staged_after = self.detect_staged_lines(hunk, file_path)?;
     988           11 :         if !staged_after.contains(&line_index) {
     989            0 :             return Err(anyhow::anyhow!(
     990            0 :                 "Failed to stage selected line {} in {}",
     991            0 :                 line_index,
     992            0 :                 file_path.display()
     993            0 :             ));
     994           11 :         }
     995              : 
     996           11 :         Ok(())
     997           11 :     }
     998              : 
     999              :     /// Unstage a single line from a hunk
    1000            4 :     pub fn unstage_single_line(
    1001            4 :         &self,
    1002            4 :         hunk: &Hunk,
    1003            4 :         line_index: usize,
    1004            4 :         file_path: &Path,
    1005            4 :     ) -> Result<()> {
    1006            4 :         let selected_line = hunk
    1007            4 :             .lines
    1008            4 :             .get(line_index)
    1009            4 :             .ok_or_else(|| anyhow::anyhow!("Line index out of bounds"))?;
    1010            4 :         if !Self::is_change_line(selected_line) {
    1011            0 :             return Err(anyhow::anyhow!("Can only unstage + or - lines"));
    1012            4 :         }
    1013              : 
    1014            4 :         let currently_staged = self.detect_staged_lines(hunk, file_path)?;
    1015            4 :         if !currently_staged.contains(&line_index) {
    1016            0 :             crate::logger::trace(format!(
    1017              :                 "unstage_single_line no-op already unstaged file={} line_index={}",
    1018            0 :                 file_path.display(),
    1019              :                 line_index
    1020              :             ));
    1021            0 :             return Ok(());
    1022            4 :         }
    1023              : 
    1024            4 :         crate::logger::debug(format!(
    1025              :             "unstage_single_line file={} line_index={}",
    1026            4 :             file_path.display(),
    1027              :             line_index
    1028              :         ));
    1029              : 
    1030            4 :         self.apply_single_line_patch_raw(hunk, line_index, file_path, true)?;
    1031              : 
    1032            4 :         let staged_after = self.detect_staged_lines(hunk, file_path)?;
    1033            4 :         if staged_after.contains(&line_index) {
    1034            0 :             return Err(anyhow::anyhow!(
    1035            0 :                 "Failed to unstage selected line {} in {}",
    1036            0 :                 line_index,
    1037            0 :                 file_path.display()
    1038            0 :             ));
    1039            4 :         }
    1040              : 
    1041            4 :         Ok(())
    1042            4 :     }
    1043              : 
    1044              :     /// Unstage an entire file
    1045            3 :     pub fn unstage_file(&self, file_path: &Path) -> Result<()> {
    1046              :         use std::process::Command;
    1047              : 
    1048            3 :         let output = Command::new("git")
    1049            3 :             .arg("reset")
    1050            3 :             .arg("HEAD")
    1051            3 :             .arg("--")
    1052            3 :             .arg(file_path)
    1053            3 :             .current_dir(&self.repo_path)
    1054            3 :             .output()?;
    1055              : 
    1056            3 :         if !output.status.success() {
    1057            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
    1058            0 :             return Err(anyhow::anyhow!("Failed to unstage file: {}", error_msg));
    1059            3 :         }
    1060              : 
    1061            3 :         Ok(())
    1062            3 :     }
    1063              : 
    1064              :     /// Unstage a specific hunk by applying the reverse patch
    1065            2 :     pub fn unstage_hunk(&self, hunk: &Hunk, file_path: &Path) -> Result<()> {
    1066              :         use std::io::Write;
    1067              :         use std::process::Command;
    1068              : 
    1069              :         // Create a proper unified diff patch
    1070            2 :         let mut patch = String::new();
    1071              : 
    1072              :         // Diff header
    1073            2 :         patch.push_str(&format!(
    1074            2 :             "diff --git a/{} b/{}\n",
    1075            2 :             file_path.display(),
    1076            2 :             file_path.display()
    1077            2 :         ));
    1078            2 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
    1079            2 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
    1080              : 
    1081              :         // Count actual add/remove lines for the hunk header
    1082            2 :         let mut old_lines = 0;
    1083            2 :         let mut new_lines = 0;
    1084            8 :         for line in &hunk.lines {
    1085            8 :             if line.starts_with('-') && !line.starts_with("---") {
    1086            2 :                 old_lines += 1;
    1087            6 :             } else if line.starts_with('+') && !line.starts_with("+++") {
    1088            2 :                 new_lines += 1;
    1089            4 :             } else if line.starts_with(' ') {
    1090            4 :                 old_lines += 1;
    1091            4 :                 new_lines += 1;
    1092            4 :             }
    1093              :         }
    1094              : 
    1095              :         // Hunk header
    1096            2 :         patch.push_str(&format!(
    1097            2 :             "@@ -{},{} +{},{} @@\n",
    1098            2 :             hunk.old_start, old_lines, hunk.new_start, new_lines
    1099            2 :         ));
    1100              : 
    1101              :         // Hunk content
    1102            8 :         for line in &hunk.lines {
    1103            8 :             patch.push_str(line);
    1104            8 :             if !line.ends_with('\n') {
    1105            0 :                 patch.push('\n');
    1106            8 :             }
    1107              :         }
    1108              : 
    1109              :         // Use git apply --reverse to unstage the hunk
    1110            2 :         let mut child = Command::new("git")
    1111            2 :             .arg("apply")
    1112            2 :             .arg("--cached")
    1113            2 :             .arg("--reverse")
    1114            2 :             .arg("--unidiff-zero")
    1115            2 :             .arg("-")
    1116            2 :             .current_dir(&self.repo_path)
    1117            2 :             .stdin(std::process::Stdio::piped())
    1118            2 :             .stdout(std::process::Stdio::piped())
    1119            2 :             .stderr(std::process::Stdio::piped())
    1120            2 :             .spawn()?;
    1121              : 
    1122            2 :         if let Some(mut stdin) = child.stdin.take() {
    1123            2 :             stdin.write_all(patch.as_bytes())?;
    1124            0 :         }
    1125              : 
    1126            2 :         let output = child.wait_with_output()?;
    1127              : 
    1128            2 :         if !output.status.success() {
    1129            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
    1130            0 :             return Err(anyhow::anyhow!("Failed to unstage hunk: {}", error_msg));
    1131            2 :         }
    1132              : 
    1133            2 :         Ok(())
    1134            2 :     }
    1135              : }
    1136              : 
    1137              : #[cfg(test)]
    1138              : #[path = "../tests/git.rs"]
    1139              : mod tests;
        

Generated by: LCOV version 2.0-1