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 : ¤tly_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;
|