LCOV - code coverage report
Current view: top level - src - git.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 72.5 % 935 678
Test Date: 2026-10-10 16:52:03 Functions: 38.4 % 112 43

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

Generated by: LCOV version 2.0-1