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