LCOV - code coverage report
Current view: top level - src - git.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 89.0 % 556 495
Test Date: 2026-02-20 16:10:39 Functions: 97.0 % 33 32

            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::{DiffSnapshot, FileChange, Hunk};
       6              : 
       7              : #[derive(Clone)]
       8              : pub struct GitRepo {
       9              :     repo_path: PathBuf,
      10              : }
      11              : 
      12              : impl GitRepo {
      13           16 :     pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
      14           16 :         let repo_path = Repository::discover(path.as_ref())
      15           16 :             .context("Failed to find git repository")?
      16           16 :             .workdir()
      17           16 :             .context("Repository has no working directory")?
      18           16 :             .to_path_buf();
      19              :         
      20           16 :         Ok(Self { repo_path })
      21           16 :     }
      22              :     
      23           12 :     pub fn repo_path(&self) -> &Path {
      24           12 :         &self.repo_path
      25           12 :     }
      26              :     
      27           15 :     pub fn get_diff_snapshot(&self) -> Result<DiffSnapshot> {
      28           15 :         let repo = Repository::open(&self.repo_path)?;
      29              :         
      30              :         // Get the diff between HEAD and working directory (includes both staged and unstaged)
      31           15 :         let mut diff_opts = DiffOptions::new();
      32           15 :         diff_opts.include_untracked(true);
      33           15 :         diff_opts.recurse_untracked_dirs(true);
      34              :         
      35              :         // Get HEAD tree (handle empty repo case)
      36           15 :         let head_tree = match repo.head() {
      37            8 :             Ok(head) => head.peel_to_tree().ok(),
      38            7 :             Err(_) => None,
      39              :         };
      40              :         
      41              :         // This shows all changes from HEAD to workdir (both staged and unstaged)
      42           15 :         let diff = repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))?;
      43              :         
      44           15 :         let mut files = Vec::new();
      45              :         
      46           15 :         diff.foreach(
      47            9 :             &mut |delta, _progress| {
      48            9 :                 let file_path = match delta.status() {
      49              :                     Delta::Added | Delta::Modified | Delta::Deleted => {
      50            9 :                         delta.new_file().path()
      51            9 :                             .or_else(|| delta.old_file().path())
      52              :                     }
      53            0 :                     _ => None,
      54              :                 };
      55              :                 
      56            9 :                 if let Some(path) = file_path {
      57            9 :                     files.push(FileChange {
      58            9 :                         path: path.to_path_buf(),
      59            9 :                         status: format!("{:?}", delta.status()),
      60            9 :                         hunks: Vec::new(),
      61            9 :                     });
      62            9 :                 }
      63            9 :                 true
      64            9 :             },
      65           15 :             None,
      66           15 :             None,
      67           15 :             None,
      68            0 :         )?;
      69              :         
      70              :         // Now get the actual diff content for each file
      71           15 :         for file in &mut files {
      72            9 :             if let Ok(hunks) = self.get_file_hunks(&repo, &file.path) {
      73            9 :                 file.hunks = hunks;
      74            9 :             }
      75              :         }
      76              :         
      77           15 :         Ok(DiffSnapshot {
      78           15 :             timestamp: std::time::SystemTime::now(),
      79           15 :             files,
      80           15 :         })
      81           15 :     }
      82              :     
      83            9 :     fn get_file_hunks(&self, repo: &Repository, path: &Path) -> Result<Vec<Hunk>> {
      84            9 :         let mut diff_opts = DiffOptions::new();
      85            9 :         diff_opts.pathspec(path);
      86            9 :         diff_opts.context_lines(3);
      87              :         
      88              :         // Get HEAD tree (handle empty repo case)
      89            9 :         let head_tree = match repo.head() {
      90            9 :             Ok(head) => head.peel_to_tree().ok(),
      91            0 :             Err(_) => None,
      92              :         };
      93              :         
      94              :         // Get diff from HEAD to workdir (includes both staged and unstaged)
      95            9 :         let diff = repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))?;
      96              :         
      97            9 :         let path_buf = path.to_path_buf();
      98              :         
      99              :         use std::cell::RefCell;
     100              :         use std::rc::Rc;
     101              :         
     102            9 :         let hunks = Rc::new(RefCell::new(Vec::new()));
     103            9 :         let current_hunk_lines = Rc::new(RefCell::new(Vec::new()));
     104            9 :         let current_old_start = Rc::new(RefCell::new(0usize));
     105            9 :         let current_new_start = Rc::new(RefCell::new(0usize));
     106            9 :         let in_hunk = Rc::new(RefCell::new(false));
     107              :         
     108            9 :         let hunks_clone = hunks.clone();
     109            9 :         let lines_clone = current_hunk_lines.clone();
     110            9 :         let old_clone = current_old_start.clone();
     111            9 :         let new_clone = current_new_start.clone();
     112            9 :         let in_hunk_clone = in_hunk.clone();
     113            9 :         let path_clone = path_buf.clone();
     114              :         
     115            9 :         let lines_clone2 = current_hunk_lines.clone();
     116            9 :         let in_hunk_clone2 = in_hunk.clone();
     117              :         
     118            9 :         diff.foreach(
     119              :             &mut |_, _| true,
     120            9 :             None,
     121            9 :             Some(&mut move |_, hunk| {
     122              :                 // Save previous hunk if exists
     123            9 :                 if *in_hunk_clone.borrow() && !lines_clone.borrow().is_empty() {
     124            0 :                     hunks_clone.borrow_mut().push(Hunk::new(
     125            0 :                         *old_clone.borrow(),
     126            0 :                         *new_clone.borrow(),
     127            0 :                         lines_clone.borrow().clone(),
     128            0 :                         &path_clone
     129            0 :                     ));
     130            0 :                     lines_clone.borrow_mut().clear();
     131            9 :                 }
     132              :                 
     133              :                 // Start new hunk
     134            9 :                 *old_clone.borrow_mut() = hunk.old_start() as usize;
     135            9 :                 *new_clone.borrow_mut() = hunk.new_start() as usize;
     136            9 :                 *in_hunk_clone.borrow_mut() = true;
     137            9 :                 true
     138            9 :             }),
     139           28 :             Some(&mut move |_, _, line| {
     140              :                 // Add line to current hunk
     141           28 :                 if *in_hunk_clone2.borrow() {
     142           28 :                     let content = String::from_utf8_lossy(line.content()).to_string();
     143           28 :                     lines_clone2.borrow_mut().push(format!("{}{}", line.origin(), content));
     144           28 :                 }
     145           28 :                 true
     146           28 :             }),
     147            0 :         )?;
     148              :         
     149              :         // Don't forget the last hunk
     150            9 :         if *in_hunk.borrow() && !current_hunk_lines.borrow().is_empty() {
     151            9 :             hunks.borrow_mut().push(Hunk::new(
     152            9 :                 *current_old_start.borrow(),
     153            9 :                 *current_new_start.borrow(),
     154            9 :                 current_hunk_lines.borrow().clone(),
     155            9 :                 &path_buf
     156            9 :             ));
     157            9 :         }
     158              :         
     159              :         // Extract the hunks - clone to avoid lifetime issues
     160            9 :         let result = hunks.borrow().clone();
     161            9 :         Ok(result)
     162            9 :     }
     163              :     
     164              :     /// Stage an entire file
     165            2 :     pub fn stage_file(&self, file_path: &Path) -> Result<()> {
     166            2 :         let repo = Repository::open(&self.repo_path)?;
     167            2 :         let mut index = repo.index()?;
     168            2 :         index.add_path(file_path)?;
     169            2 :         index.write()?;
     170            2 :         Ok(())
     171            2 :     }
     172              :     
     173              :     /// Stage a specific hunk by applying it as a patch
     174            2 :     pub fn stage_hunk(&self, hunk: &Hunk, file_path: &Path) -> Result<()> {
     175              :         use std::process::Command;
     176              :         use std::io::Write;
     177              :         
     178              :         // Create a proper unified diff patch
     179            2 :         let mut patch = String::new();
     180              :         
     181              :         // Diff header
     182            2 :         patch.push_str(&format!("diff --git a/{} b/{}\n", file_path.display(), file_path.display()));
     183            2 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
     184            2 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
     185              :         
     186              :         // Count actual add/remove lines for the hunk header
     187            2 :         let mut old_lines = 0;
     188            2 :         let mut new_lines = 0;
     189            8 :         for line in &hunk.lines {
     190            8 :             if line.starts_with('-') && !line.starts_with("---") {
     191            2 :                 old_lines += 1;
     192            6 :             } else if line.starts_with('+') && !line.starts_with("+++") {
     193            2 :                 new_lines += 1;
     194            4 :             } else if line.starts_with(' ') {
     195            4 :                 old_lines += 1;
     196            4 :                 new_lines += 1;
     197            4 :             }
     198              :         }
     199              :         
     200              :         // Hunk header
     201            2 :         patch.push_str(&format!("@@ -{},{} +{},{} @@\n", 
     202            2 :             hunk.old_start, 
     203            2 :             old_lines, 
     204            2 :             hunk.new_start, 
     205            2 :             new_lines
     206            2 :         ));
     207              :         
     208              :         // Hunk content
     209            8 :         for line in &hunk.lines {
     210            8 :             patch.push_str(line);
     211            8 :             if !line.ends_with('\n') {
     212            0 :                 patch.push('\n');
     213            8 :             }
     214              :         }
     215              :         
     216              :         // Use git apply to stage the hunk
     217            2 :         let mut child = Command::new("git")
     218            2 :             .arg("apply")
     219            2 :             .arg("--cached")
     220            2 :             .arg("--unidiff-zero")
     221            2 :             .arg("-")
     222            2 :             .current_dir(&self.repo_path)
     223            2 :             .stdin(std::process::Stdio::piped())
     224            2 :             .stdout(std::process::Stdio::piped())
     225            2 :             .stderr(std::process::Stdio::piped())
     226            2 :             .spawn()?;
     227              :         
     228            2 :         if let Some(mut stdin) = child.stdin.take() {
     229            2 :             stdin.write_all(patch.as_bytes())?;
     230            0 :         }
     231              :         
     232            2 :         let output = child.wait_with_output()?;
     233              :         
     234            2 :         if !output.status.success() {
     235            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     236            0 :             return Err(anyhow::anyhow!("Failed to stage hunk: {}", error_msg));
     237            2 :         }
     238              :         
     239            2 :         Ok(())
     240            2 :     }
     241              :     
     242              :     /// Detect which lines in a hunk are currently staged in the index
     243              :     /// Returns a HashSet of line indices that are staged
     244            6 :     pub fn detect_staged_lines(&self, hunk: &Hunk, file_path: &Path) -> Result<std::collections::HashSet<usize>> {
     245              :         use std::collections::HashSet;
     246              :         
     247            6 :         let repo = Repository::open(&self.repo_path)?;
     248              :         
     249              :         // Get diff from HEAD to index (only staged changes)
     250            6 :         let head_tree = match repo.head() {
     251            6 :             Ok(head) => head.peel_to_tree().ok(),
     252            0 :             Err(_) => None,
     253              :         };
     254              :         
     255            6 :         let mut diff_opts = DiffOptions::new();
     256            6 :         diff_opts.pathspec(file_path);
     257              :         
     258            6 :         let diff = repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?;
     259              :         
     260            6 :         let mut staged_lines = HashSet::new();
     261              :         
     262              :         // Collect all hunks from the staged diff with their line ranges
     263              :         use std::cell::RefCell;
     264            6 :         let staged_hunks = RefCell::new(Vec::new());
     265              :         
     266            6 :         diff.foreach(
     267              :             &mut |_, _| true,
     268            6 :             None,
     269            1 :             Some(&mut |_, diff_hunk| {
     270              :                 // Store the hunk's old_start to identify it
     271            1 :                 staged_hunks.borrow_mut().push((diff_hunk.old_start() as usize, Vec::new()));
     272            1 :                 true
     273            1 :             }),
     274            4 :             Some(&mut |_, _, line| {
     275            4 :                 let content = String::from_utf8_lossy(line.content()).to_string();
     276            4 :                 let line_str = format!("{}{}", line.origin(), content);
     277              :                 
     278              :                 // Add to the most recent hunk
     279            4 :                 if let Some(last_hunk) = staged_hunks.borrow_mut().last_mut() {
     280            4 :                     last_hunk.1.push(line_str);
     281            4 :                 }
     282            4 :                 true
     283            4 :             }),
     284            0 :         )?;
     285              :         
     286            6 :         let staged_hunks = staged_hunks.into_inner();
     287              :         
     288              :         // Find the matching staged hunk by old_start position
     289            6 :         let matching_staged_hunk = staged_hunks.iter()
     290            6 :             .find(|(old_start, _)| *old_start == hunk.old_start);
     291              :         
     292            6 :         if let Some((_, staged_hunk_lines)) = matching_staged_hunk {
     293              :             // Match lines from our hunk with the staged hunk's lines
     294            4 :             for (hunk_idx, hunk_line) in hunk.lines.iter().enumerate() {
     295              :                 // Only check change lines (+ or -)
     296            4 :                 if (hunk_line.starts_with('+') && !hunk_line.starts_with("+++")) ||
     297            3 :                    (hunk_line.starts_with('-') && !hunk_line.starts_with("---")) {
     298              :                     // Check if this exact line exists in the matching staged hunk
     299            5 :                     if staged_hunk_lines.iter().any(|staged_line| {
     300            5 :                         staged_line.trim_end() == hunk_line.trim_end()
     301            5 :                     }) {
     302            2 :                         staged_lines.insert(hunk_idx);
     303            2 :                     }
     304            2 :                 }
     305              :             }
     306            5 :         }
     307              :         
     308            6 :         Ok(staged_lines)
     309            6 :     }
     310              :     
     311              :     /// Stage a single line from a hunk
     312            2 :     pub fn stage_single_line(&self, hunk: &Hunk, line_index: usize, file_path: &Path) -> Result<()> {
     313              :         use std::process::Command;
     314              :         use std::io::Write;
     315              :         
     316              :         // Verify the line exists
     317            2 :         if line_index >= hunk.lines.len() {
     318            0 :             return Err(anyhow::anyhow!("Line index out of bounds"));
     319            2 :         }
     320              :         
     321            2 :         let selected_line = &hunk.lines[line_index];
     322              :         
     323              :         // Only allow staging change lines
     324            2 :         if !((selected_line.starts_with('+') && !selected_line.starts_with("+++")) ||
     325            0 :              (selected_line.starts_with('-') && !selected_line.starts_with("---"))) {
     326            0 :             return Err(anyhow::anyhow!("Can only stage + or - lines"));
     327            2 :         }
     328              :         
     329              :         // For now, let's use a simpler approach: stage the whole hunk
     330              :         // In a production implementation, you'd want to use git add --interactive style patching
     331              :         // or use libgit2's apply functionality with more precise patches
     332              :         
     333              :         // Create a patch with just this single line change
     334            2 :         let mut patch = String::new();
     335              :         
     336              :         // Diff header
     337            2 :         patch.push_str(&format!("diff --git a/{} b/{}\n", file_path.display(), file_path.display()));
     338            2 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
     339            2 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
     340              :         
     341              :         // For single-line staging, we need proper context from the hunk
     342              :         // Find all context lines around our target line
     343            2 :         let mut context_before = Vec::new();
     344            2 :         let mut context_after = Vec::new();
     345              :         
     346              :         // Collect context before the selected line
     347            2 :         let mut i = line_index;
     348            2 :         while i > 0 && context_before.len() < 3 {
     349            2 :             i -= 1;
     350            2 :             let line = &hunk.lines[i];
     351            2 :             if line.starts_with(' ') {
     352            0 :                 context_before.insert(0, line.clone());
     353            0 :             } else {
     354              :                 // Hit another change line, stop
     355            2 :                 break;
     356              :             }
     357              :         }
     358              :         
     359              :         // Collect context after the selected line
     360            2 :         let mut i = line_index + 1;
     361            4 :         while i < hunk.lines.len() && context_after.len() < 3 {
     362            2 :             let line = &hunk.lines[i];
     363            2 :             if line.starts_with(' ') {
     364            2 :                 context_after.push(line.clone());
     365            2 :                 i += 1;
     366            2 :             } else {
     367              :                 // Hit another change line, stop
     368            0 :                 break;
     369              :             }
     370              :         }
     371              :         
     372              :         // Calculate line numbers for the hunk header
     373              :         // This is approximate - we're counting context lines to estimate position
     374            2 :         let is_addition = selected_line.starts_with('+');
     375            2 :         let context_before_count = context_before.len();
     376              :         
     377            2 :         let old_line_count = context_before_count + if is_addition { 0 } else { 1 } + context_after.len();
     378            2 :         let new_line_count = context_before_count + if is_addition { 1 } else { 0 } + context_after.len();
     379              :         
     380              :         // Estimate old_start and new_start (this is approximate)
     381            2 :         let estimated_old_start = hunk.old_start + line_index - context_before_count;
     382            2 :         let estimated_new_start = hunk.new_start + line_index - context_before_count;
     383              :         
     384              :         // Write hunk header
     385            2 :         patch.push_str(&format!("@@ -{},{} +{},{} @@\n",
     386            2 :             estimated_old_start,
     387            2 :             old_line_count,
     388            2 :             estimated_new_start,
     389            2 :             new_line_count
     390            2 :         ));
     391              :         
     392              :         // Write context before
     393            2 :         for line in &context_before {
     394            0 :             patch.push_str(line);
     395            0 :             if !line.ends_with('\n') {
     396            0 :                 patch.push('\n');
     397            0 :             }
     398              :         }
     399              :         
     400              :         // Write the selected line
     401            2 :         patch.push_str(selected_line);
     402            2 :         if !selected_line.ends_with('\n') {
     403            0 :             patch.push('\n');
     404            2 :         }
     405              :         
     406              :         // Write context after
     407            2 :         for line in &context_after {
     408            2 :             patch.push_str(line);
     409            2 :             if !line.ends_with('\n') {
     410            0 :                 patch.push('\n');
     411            2 :             }
     412              :         }
     413              :         
     414              :         // Try to apply the patch
     415            2 :         let mut child = Command::new("git")
     416            2 :             .arg("apply")
     417            2 :             .arg("--cached")
     418            2 :             .arg("--unidiff-zero")
     419            2 :             .arg("--allow-overlap")
     420            2 :             .arg("-")
     421            2 :             .current_dir(&self.repo_path)
     422            2 :             .stdin(std::process::Stdio::piped())
     423            2 :             .stdout(std::process::Stdio::piped())
     424            2 :             .stderr(std::process::Stdio::piped())
     425            2 :             .spawn()?;
     426              :         
     427            2 :         if let Some(mut stdin) = child.stdin.take() {
     428            2 :             stdin.write_all(patch.as_bytes())?;
     429            0 :         }
     430              :         
     431            2 :         let output = child.wait_with_output()?;
     432              :         
     433            2 :         if !output.status.success() {
     434            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     435            0 :             let patch_preview = if patch.len() > 500 {
     436            0 :                 format!("{}... (truncated)", &patch[..500])
     437              :             } else {
     438            0 :                 patch.clone()
     439              :             };
     440            0 :             return Err(anyhow::anyhow!("Failed to stage line: {}\nPatch was:\n{}", error_msg, patch_preview));
     441            2 :         }
     442              :         
     443            2 :         Ok(())
     444            2 :     }
     445              :     
     446              :     /// Unstage a single line from a hunk
     447            2 :     pub fn unstage_single_line(&self, hunk: &Hunk, line_index: usize, file_path: &Path) -> Result<()> {
     448              :         use std::process::Command;
     449              :         use std::io::Write;
     450              :         
     451              :         // Verify the line exists
     452            2 :         if line_index >= hunk.lines.len() {
     453            0 :             return Err(anyhow::anyhow!("Line index out of bounds"));
     454            2 :         }
     455              :         
     456            2 :         let selected_line = &hunk.lines[line_index];
     457              :         
     458              :         // Only allow unstaging change lines
     459            2 :         if !((selected_line.starts_with('+') && !selected_line.starts_with("+++")) ||
     460            0 :              (selected_line.starts_with('-') && !selected_line.starts_with("---"))) {
     461            0 :             return Err(anyhow::anyhow!("Can only unstage + or - lines"));
     462            2 :         }
     463              :         
     464              :         // Create a reverse patch to unstage the line
     465              :         // For unstaging, we need to reverse the operation:
     466              :         // - If the line is "+something", we remove it from the index (reverse: "-something")
     467              :         // - If the line is "-something", we add it back to the index (reverse: "+something")
     468              :         
     469            2 :         let mut patch = String::new();
     470              :         
     471              :         // Diff header
     472            2 :         patch.push_str(&format!("diff --git a/{} b/{}\n", file_path.display(), file_path.display()));
     473            2 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
     474            2 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
     475              :         
     476              :         // Find context lines around the target line
     477            2 :         let mut context_before = Vec::new();
     478            2 :         let mut context_after = Vec::new();
     479              :         
     480              :         // Collect context before the selected line
     481            2 :         let mut i = line_index;
     482            2 :         while i > 0 && context_before.len() < 3 {
     483            2 :             i -= 1;
     484            2 :             let line = &hunk.lines[i];
     485            2 :             if line.starts_with(' ') {
     486            0 :                 context_before.insert(0, line.clone());
     487            0 :             } else {
     488            2 :                 break;
     489              :             }
     490              :         }
     491              :         
     492              :         // Collect context after the selected line
     493            2 :         let mut i = line_index + 1;
     494            4 :         while i < hunk.lines.len() && context_after.len() < 3 {
     495            2 :             let line = &hunk.lines[i];
     496            2 :             if line.starts_with(' ') {
     497            2 :                 context_after.push(line.clone());
     498            2 :                 i += 1;
     499            2 :             } else {
     500            0 :                 break;
     501              :             }
     502              :         }
     503              :         
     504              :         // For unstaging, we apply the SAME patch as staging but with --reverse flag
     505              :         // Don't manually reverse the line - git apply --reverse will do that
     506              :         
     507              :         // Calculate line numbers for the hunk header
     508            2 :         let is_addition = selected_line.starts_with('+');
     509            2 :         let context_before_count = context_before.len();
     510              :         
     511            2 :         let old_line_count = context_before_count + if is_addition { 0 } else { 1 } + context_after.len();
     512            2 :         let new_line_count = context_before_count + if is_addition { 1 } else { 0 } + context_after.len();
     513              :         
     514            2 :         let estimated_old_start = hunk.old_start + line_index - context_before_count;
     515            2 :         let estimated_new_start = hunk.new_start + line_index - context_before_count;
     516              :         
     517              :         // Write hunk header
     518            2 :         patch.push_str(&format!("@@ -{},{} +{},{} @@\n",
     519            2 :             estimated_old_start,
     520            2 :             old_line_count,
     521            2 :             estimated_new_start,
     522            2 :             new_line_count
     523            2 :         ));
     524              :         
     525              :         // Write context before
     526            2 :         for line in &context_before {
     527            0 :             patch.push_str(line);
     528            0 :             if !line.ends_with('\n') {
     529            0 :                 patch.push('\n');
     530            0 :             }
     531              :         }
     532              :         
     533              :         // Write the selected line (not reversed - git apply --reverse will handle that)
     534            2 :         patch.push_str(selected_line);
     535            2 :         if !selected_line.ends_with('\n') {
     536            0 :             patch.push('\n');
     537            2 :         }
     538              :         
     539              :         // Write context after
     540            2 :         for line in &context_after {
     541            2 :             patch.push_str(line);
     542            2 :             if !line.ends_with('\n') {
     543            0 :                 patch.push('\n');
     544            2 :             }
     545              :         }
     546              :         
     547              :         // Apply the reverse patch to the index using --cached and --reverse
     548            2 :         let mut child = Command::new("git")
     549            2 :             .arg("apply")
     550            2 :             .arg("--cached")
     551            2 :             .arg("--reverse")
     552            2 :             .arg("--unidiff-zero")
     553            2 :             .arg("--allow-overlap")
     554            2 :             .arg("-")
     555            2 :             .current_dir(&self.repo_path)
     556            2 :             .stdin(std::process::Stdio::piped())
     557            2 :             .stdout(std::process::Stdio::piped())
     558            2 :             .stderr(std::process::Stdio::piped())
     559            2 :             .spawn()?;
     560              :         
     561            2 :         if let Some(mut stdin) = child.stdin.take() {
     562            2 :             stdin.write_all(patch.as_bytes())?;
     563            0 :         }
     564              :         
     565            2 :         let output = child.wait_with_output()?;
     566              :         
     567            2 :         if !output.status.success() {
     568            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     569            0 :             let patch_preview = if patch.len() > 500 {
     570            0 :                 format!("{}... (truncated)", &patch[..500])
     571              :             } else {
     572            0 :                 patch.clone()
     573              :             };
     574            0 :             return Err(anyhow::anyhow!("Failed to unstage line: {}\nPatch was:\n{}", error_msg, patch_preview));
     575            2 :         }
     576              :         
     577            2 :         Ok(())
     578            2 :     }
     579              :     
     580              :     /// Unstage an entire file
     581            2 :     pub fn unstage_file(&self, file_path: &Path) -> Result<()> {
     582              :         use std::process::Command;
     583              :         
     584            2 :         let output = Command::new("git")
     585            2 :             .arg("reset")
     586            2 :             .arg("HEAD")
     587            2 :             .arg("--")
     588            2 :             .arg(file_path)
     589            2 :             .current_dir(&self.repo_path)
     590            2 :             .output()?;
     591              :         
     592            2 :         if !output.status.success() {
     593            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     594            0 :             return Err(anyhow::anyhow!("Failed to unstage file: {}", error_msg));
     595            2 :         }
     596              :         
     597            2 :         Ok(())
     598            2 :     }
     599              :     
     600              :     /// Unstage a specific hunk by applying the reverse patch
     601            2 :     pub fn unstage_hunk(&self, hunk: &Hunk, file_path: &Path) -> Result<()> {
     602              :         use std::process::Command;
     603              :         use std::io::Write;
     604              :         
     605              :         // Create a proper unified diff patch
     606            2 :         let mut patch = String::new();
     607              :         
     608              :         // Diff header
     609            2 :         patch.push_str(&format!("diff --git a/{} b/{}\n", file_path.display(), file_path.display()));
     610            2 :         patch.push_str(&format!("--- a/{}\n", file_path.display()));
     611            2 :         patch.push_str(&format!("+++ b/{}\n", file_path.display()));
     612              :         
     613              :         // Count actual add/remove lines for the hunk header
     614            2 :         let mut old_lines = 0;
     615            2 :         let mut new_lines = 0;
     616            8 :         for line in &hunk.lines {
     617            8 :             if line.starts_with('-') && !line.starts_with("---") {
     618            2 :                 old_lines += 1;
     619            6 :             } else if line.starts_with('+') && !line.starts_with("+++") {
     620            2 :                 new_lines += 1;
     621            4 :             } else if line.starts_with(' ') {
     622            4 :                 old_lines += 1;
     623            4 :                 new_lines += 1;
     624            4 :             }
     625              :         }
     626              :         
     627              :         // Hunk header
     628            2 :         patch.push_str(&format!("@@ -{},{} +{},{} @@\n", 
     629            2 :             hunk.old_start, 
     630            2 :             old_lines, 
     631            2 :             hunk.new_start, 
     632            2 :             new_lines
     633            2 :         ));
     634              :         
     635              :         // Hunk content
     636            8 :         for line in &hunk.lines {
     637            8 :             patch.push_str(line);
     638            8 :             if !line.ends_with('\n') {
     639            0 :                 patch.push('\n');
     640            8 :             }
     641              :         }
     642              :         
     643              :         // Use git apply --reverse to unstage the hunk
     644            2 :         let mut child = Command::new("git")
     645            2 :             .arg("apply")
     646            2 :             .arg("--cached")
     647            2 :             .arg("--reverse")
     648            2 :             .arg("--unidiff-zero")
     649            2 :             .arg("-")
     650            2 :             .current_dir(&self.repo_path)
     651            2 :             .stdin(std::process::Stdio::piped())
     652            2 :             .stdout(std::process::Stdio::piped())
     653            2 :             .stderr(std::process::Stdio::piped())
     654            2 :             .spawn()?;
     655              :         
     656            2 :         if let Some(mut stdin) = child.stdin.take() {
     657            2 :             stdin.write_all(patch.as_bytes())?;
     658            0 :         }
     659              :         
     660            2 :         let output = child.wait_with_output()?;
     661              :         
     662            2 :         if !output.status.success() {
     663            0 :             let error_msg = String::from_utf8_lossy(&output.stderr);
     664            0 :             return Err(anyhow::anyhow!("Failed to unstage hunk: {}", error_msg));
     665            2 :         }
     666              :         
     667            2 :         Ok(())
     668            2 :     }
     669              : }
     670              : 
     671              : #[cfg(test)]
     672              : mod tests {
     673              :     use super::*;
     674              :     use std::fs;
     675              :     use std::process::Command;
     676              :     use std::time::{SystemTime, UNIX_EPOCH};
     677              : 
     678              :     struct TestRepo {
     679              :         path: PathBuf,
     680              :     }
     681              : 
     682              :     impl TestRepo {
     683            4 :         fn new() -> Self {
     684            4 :             let unique = SystemTime::now()
     685            4 :                 .duration_since(UNIX_EPOCH)
     686            4 :                 .expect("failed to get system time")
     687            4 :                 .as_nanos();
     688            4 :             let path = std::env::temp_dir().join(format!("hunky-git-tests-{}-{}", std::process::id(), unique));
     689              : 
     690            4 :             fs::create_dir_all(&path).expect("failed to create temp directory");
     691              : 
     692            4 :             run_git(&path, &["init"]);
     693            4 :             run_git(&path, &["config", "user.name", "Test User"]);
     694            4 :             run_git(&path, &["config", "user.email", "test@example.com"]);
     695              : 
     696            4 :             Self { path }
     697            4 :         }
     698              : 
     699            8 :         fn write_file(&self, rel_path: &str, content: &str) {
     700            8 :             fs::write(self.path.join(rel_path), content).expect("failed to write file");
     701            8 :         }
     702              : 
     703            4 :         fn commit_all(&self, message: &str) {
     704            4 :             run_git(&self.path, &["add", "."]);
     705            4 :             run_git(&self.path, &["commit", "-m", message]);
     706            4 :         }
     707              :     }
     708              : 
     709              :     impl Drop for TestRepo {
     710            4 :         fn drop(&mut self) {
     711            4 :             let _ = fs::remove_dir_all(&self.path);
     712            4 :         }
     713              :     }
     714              : 
     715           26 :     fn run_git(repo_path: &Path, args: &[&str]) -> String {
     716           26 :         let output = Command::new("git")
     717           26 :             .args(args)
     718           26 :             .current_dir(repo_path)
     719           26 :             .output()
     720           26 :             .expect("failed to execute git");
     721              : 
     722           26 :         if !output.status.success() {
     723            0 :             panic!(
     724              :                 "git {:?} failed: {}",
     725              :                 args,
     726            0 :                 String::from_utf8_lossy(&output.stderr)
     727              :             );
     728           26 :         }
     729              : 
     730           26 :         String::from_utf8_lossy(&output.stdout).to_string()
     731           26 :     }
     732              : 
     733              :     #[test]
     734            1 :     fn stage_and_unstage_file_updates_index() {
     735            1 :         let repo = TestRepo::new();
     736            1 :         repo.write_file("example.txt", "line 1\nline 2\n");
     737            1 :         repo.commit_all("initial");
     738            1 :         repo.write_file("example.txt", "line 1\nline 2\nline 3\n");
     739              : 
     740            1 :         let git_repo = GitRepo::new(&repo.path).expect("failed to open test repo");
     741            1 :         let file_path = Path::new("example.txt");
     742              : 
     743            1 :         git_repo.stage_file(file_path).expect("failed to stage file");
     744            1 :         let staged = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
     745            1 :         assert!(staged.contains("example.txt"));
     746              : 
     747            1 :         git_repo
     748            1 :             .unstage_file(file_path)
     749            1 :             .expect("failed to unstage file");
     750            1 :         let staged_after = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
     751            1 :         assert!(staged_after.trim().is_empty());
     752            1 :     }
     753              : 
     754              :     #[test]
     755            1 :     fn stage_and_unstage_hunk_updates_index() {
     756            1 :         let repo = TestRepo::new();
     757            1 :         repo.write_file("example.txt", "line 1\nline 2\nline 3\n");
     758            1 :         repo.commit_all("initial");
     759            1 :         repo.write_file("example.txt", "line 1\nline two updated\nline 3\n");
     760              : 
     761            1 :         let git_repo = GitRepo::new(&repo.path).expect("failed to open test repo");
     762            1 :         let snapshot = git_repo
     763            1 :             .get_diff_snapshot()
     764            1 :             .expect("failed to get diff snapshot");
     765            1 :         let file_change = snapshot
     766            1 :             .files
     767            1 :             .iter()
     768            1 :             .find(|file| file.path == PathBuf::from("example.txt"))
     769            1 :             .expect("expected file in diff");
     770            1 :         let hunk = file_change.hunks.first().expect("expected hunk");
     771            1 :         let file_path = Path::new("example.txt");
     772              : 
     773            1 :         git_repo
     774            1 :             .stage_hunk(hunk, file_path)
     775            1 :             .expect("failed to stage hunk");
     776            1 :         let staged = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
     777            1 :         assert!(staged.contains("example.txt"));
     778              : 
     779            1 :         let staged_lines = git_repo
     780            1 :             .detect_staged_lines(hunk, file_path)
     781            1 :             .expect("failed to detect staged lines");
     782            1 :         assert!(!staged_lines.is_empty());
     783              : 
     784            1 :         git_repo
     785            1 :             .unstage_hunk(hunk, file_path)
     786            1 :             .expect("failed to unstage hunk");
     787            1 :         let staged_after = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
     788            1 :         assert!(staged_after.trim().is_empty());
     789            1 :     }
     790              : 
     791              :     #[test]
     792            1 :     fn stage_and_unstage_single_line_tracks_index_changes() {
     793            1 :         let repo = TestRepo::new();
     794            1 :         repo.write_file("example.txt", "one\ntwo\nthree\n");
     795            1 :         repo.commit_all("initial");
     796            1 :         repo.write_file("example.txt", "one\ntwo-updated\nthree\n");
     797              : 
     798            1 :         let git_repo = GitRepo::new(&repo.path).expect("failed to open test repo");
     799            1 :         let snapshot = git_repo
     800            1 :             .get_diff_snapshot()
     801            1 :             .expect("failed to get diff snapshot");
     802            1 :         let file_change = snapshot
     803            1 :             .files
     804            1 :             .iter()
     805            1 :             .find(|file| file.path == PathBuf::from("example.txt"))
     806            1 :             .expect("expected file in diff");
     807            1 :         let hunk = file_change.hunks.first().expect("expected hunk");
     808            1 :         let line_index = hunk
     809            1 :             .lines
     810            1 :             .iter()
     811            3 :             .position(|line| line.starts_with('+') && !line.starts_with("+++"))
     812            1 :             .expect("expected added line");
     813              : 
     814            1 :         git_repo
     815            1 :             .stage_single_line(hunk, line_index, Path::new("example.txt"))
     816            1 :             .expect("failed to stage single line");
     817            1 :         let staged = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
     818            1 :         assert!(staged.contains("example.txt"));
     819              : 
     820            1 :         git_repo
     821            1 :             .unstage_single_line(hunk, line_index, Path::new("example.txt"))
     822            1 :             .expect("failed to unstage single line");
     823            1 :         let staged_after = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
     824            1 :         assert!(staged_after.trim().is_empty());
     825            1 :     }
     826              : 
     827              :     #[test]
     828            1 :     fn diff_snapshot_reports_file_status() {
     829            1 :         let repo = TestRepo::new();
     830            1 :         repo.write_file("status.txt", "hello\n");
     831            1 :         repo.commit_all("initial");
     832            1 :         repo.write_file("status.txt", "hello world\n");
     833              : 
     834            1 :         let git_repo = GitRepo::new(&repo.path).expect("failed to open test repo");
     835            1 :         let snapshot = git_repo
     836            1 :             .get_diff_snapshot()
     837            1 :             .expect("failed to get diff snapshot");
     838            1 :         let file = snapshot
     839            1 :             .files
     840            1 :             .iter()
     841            1 :             .find(|f| f.path == PathBuf::from("status.txt"))
     842            1 :             .expect("expected changed file");
     843            1 :         assert_eq!(file.status, "Modified");
     844            1 :         assert!(!file.hunks.is_empty());
     845            1 :     }
     846              : }
        

Generated by: LCOV version 2.0-1