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 : }
|