1pub mod memory;
6
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum HunkKind {
11 Add,
12 Change,
13 Delete,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LineOrigin {
20 Context,
21 Addition,
22 Deletion,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct DiffLine {
29 pub origin: LineOrigin,
30 pub old_lineno: Option<usize>,
31 pub new_lineno: Option<usize>,
32 pub text: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct Hunk {
38 pub kind: HunkKind,
39 pub new_start: usize,
42 pub new_count: usize,
43 pub old_start: usize,
44 pub old_count: usize,
45 pub lines: Vec<DiffLine>,
46}
47
48#[derive(Debug, Clone)]
50pub struct FileDiff {
51 pub path: PathBuf,
52 pub hunks: Vec<Hunk>,
53 pub added: usize,
54 pub deleted: usize,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Sign {
61 AddOrChange,
63 DeleteAfter,
66}
67
68impl Hunk {
69 pub fn signs(&self) -> Vec<(usize, Sign)> {
71 let mut out = Vec::new();
72 let mut nl = self.new_start;
73 for line in &self.lines {
74 match line.origin {
75 LineOrigin::Addition => {
76 out.push((nl, Sign::AddOrChange));
77 nl += 1;
78 }
79 LineOrigin::Deletion => out.push((nl, Sign::DeleteAfter)),
80 LineOrigin::Context => nl += 1,
81 }
82 }
83 out
84 }
85
86 pub fn changed_region(&self) -> (usize, usize, usize, usize) {
91 let mut nl = self.new_start;
92 let mut ol = self.old_start;
93 let mut new_lines = Vec::new();
94 let mut old_lines = Vec::new();
95 for line in &self.lines {
96 match line.origin {
97 LineOrigin::Addition => {
98 new_lines.push(nl);
99 nl += 1;
100 }
101 LineOrigin::Deletion => {
102 old_lines.push(ol);
103 ol += 1;
104 }
105 LineOrigin::Context => {
106 nl += 1;
107 ol += 1;
108 }
109 }
110 }
111 let new_first = new_lines.first().copied().unwrap_or(nl);
112 let old_first = old_lines.first().copied().unwrap_or(ol);
113 (new_first, new_lines.len(), old_first, old_lines.len())
114 }
115
116 pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
119 self.signs().iter().any(|&(l, kind)| match kind {
120 Sign::AddOrChange => l == line_1based,
121 Sign::DeleteAfter => l.min(total_lines) == line_1based,
122 })
123 }
124
125 pub fn to_patch(&self, rel: &Path) -> String {
128 let mut patch = format!("--- a/{}\n+++ b/{}\n", rel.display(), rel.display());
129 patch.push_str(&format!(
130 "@@ -{},{} +{},{} @@\n",
131 self.old_start, self.old_count, self.new_start, self.new_count
132 ));
133 for line in &self.lines {
134 let prefix = match line.origin {
135 LineOrigin::Addition => '+',
136 LineOrigin::Deletion => '-',
137 LineOrigin::Context => ' ',
138 };
139 patch.push(prefix);
140 patch.push_str(&line.text);
141 patch.push('\n');
142 }
143 patch
144 }
145
146 pub fn header(&self) -> String {
148 format!(
149 "@@ -{},{} +{},{} @@",
150 self.old_start, self.old_count, self.new_start, self.new_count
151 )
152 }
153
154 pub fn build(
158 old_start: usize,
159 old_count: usize,
160 new_start: usize,
161 new_count: usize,
162 lines: Vec<DiffLine>,
163 ) -> Self {
164 let has_add = lines.iter().any(|l| l.origin == LineOrigin::Addition);
165 let has_del = lines.iter().any(|l| l.origin == LineOrigin::Deletion);
166 let kind = match (has_add, has_del) {
167 (true, false) => HunkKind::Add,
168 (false, true) => HunkKind::Delete,
169 _ => HunkKind::Change,
170 };
171 Hunk {
172 kind,
173 new_start,
174 new_count,
175 old_start,
176 old_count,
177 lines,
178 }
179 }
180}
181
182pub struct Repo {
183 inner: git2::Repository,
184 workdir: PathBuf,
185}
186
187impl Repo {
188 pub fn discover(from: &Path) -> Option<Self> {
190 let inner = git2::Repository::discover(from).ok()?;
191 let workdir = inner.workdir()?.to_path_buf();
192 Some(Self { inner, workdir })
193 }
194
195 pub fn workdir(&self) -> &Path {
196 &self.workdir
197 }
198
199 pub fn remotes(&self) -> Vec<(String, String)> {
201 let Ok(remotes) = self.inner.remotes() else {
202 return vec![];
203 };
204 remotes
205 .iter()
206 .flatten()
207 .filter_map(|name| {
208 self.inner
209 .find_remote(name)
210 .ok()
211 .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
212 })
213 .collect()
214 }
215
216 pub fn head_sha(&self) -> Option<String> {
218 Some(
219 self.inner
220 .head()
221 .ok()?
222 .peel_to_commit()
223 .ok()?
224 .id()
225 .to_string(),
226 )
227 }
228
229 pub fn head_branch(&self) -> Option<String> {
231 self.inner
232 .head()
233 .ok()
234 .and_then(|h| h.shorthand().map(String::from))
235 }
236
237 fn rel_path(&self, path: &Path) -> Option<PathBuf> {
239 let abs = if path.is_absolute() {
240 path.to_path_buf()
241 } else {
242 self.workdir.join(path)
243 };
244 abs.strip_prefix(&self.workdir)
245 .ok()
246 .map(|p| p.to_path_buf())
247 }
248
249 pub fn head_content(&self, path: &Path) -> Option<String> {
251 let rel = self.rel_path(path)?;
252 let head = self.inner.head().ok()?.peel_to_tree().ok()?;
253 let entry = head.get_path(&rel).ok()?;
254 let blob = self.inner.find_blob(entry.id()).ok()?;
255 String::from_utf8(blob.content().to_vec()).ok()
256 }
257
258 pub fn index_content(&self, path: &Path) -> Option<String> {
260 let rel = self.rel_path(path)?;
261 let mut index = self.inner.index().ok()?;
264 index.read(true).ok()?;
265 let entry = index.get_path(&rel, 0)?;
266 let blob = self.inner.find_blob(entry.id).ok()?;
267 String::from_utf8(blob.content().to_vec()).ok()
268 }
269
270 pub fn staged_hunks(&self, path: &Path) -> Vec<Hunk> {
274 let Some(rel) = self.rel_path(path) else {
275 return vec![];
276 };
277 let (Some(head), Some(index)) = (self.head_content(path), self.index_content(path)) else {
278 return vec![];
279 };
280 self.diff_strings(&head, &index, &rel)
281 }
282
283 pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
287 let Some(rel) = self.rel_path(path) else {
288 return vec![];
289 };
290 let base = self.index_content(path).or_else(|| self.head_content(path));
291 match base {
292 None => self.hunks(path, content), Some(base) => self.diff_strings(&base, content, &rel),
294 }
295 }
296
297 pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
299 let patch = hunk.to_patch(rel);
300 let mut child = std::process::Command::new("git")
301 .args([
302 "-C",
303 &self.workdir.display().to_string(),
304 "apply",
305 "--cached",
306 "--reverse",
307 "--unidiff-zero",
308 ])
309 .stdin(std::process::Stdio::piped())
310 .stdout(std::process::Stdio::null())
311 .stderr(std::process::Stdio::piped())
312 .spawn()
313 .map_err(|e| format!("spawn git: {e}"))?;
314 use std::io::Write;
315 child
316 .stdin
317 .as_mut()
318 .expect("piped")
319 .write_all(patch.as_bytes())
320 .map_err(|e| e.to_string())?;
321 let out = child.wait_with_output().map_err(|e| e.to_string())?;
322 if out.status.success() {
323 Ok(())
324 } else {
325 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
326 }
327 }
328
329 pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
332 let Some(rel) = self.rel_path(path) else {
333 return vec![];
334 };
335 let old = self.head_content(path);
336 match old {
337 None => {
338 let count = content.lines().count();
339 if count == 0 {
340 return vec![];
341 }
342 vec![Hunk {
343 kind: HunkKind::Add,
344 new_start: 1,
345 new_count: count,
346 old_start: 0,
347 old_count: 0,
348 lines: content
349 .lines()
350 .enumerate()
351 .map(|(i, l)| DiffLine {
352 origin: LineOrigin::Addition,
353 old_lineno: None,
354 new_lineno: Some(i + 1),
355 text: l.to_string(),
356 })
357 .collect(),
358 }]
359 }
360 Some(old) => self.diff_strings(&old, content, &rel),
361 }
362 }
363
364 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
365 let mut opts = git2::DiffOptions::new();
366 opts.context_lines(3);
367 let Ok(patch) = git2::Patch::from_buffers(
368 old.as_bytes(),
369 Some(rel),
370 new.as_bytes(),
371 Some(rel),
372 Some(&mut opts),
373 ) else {
374 return vec![];
375 };
376 hunks_from_patch(&patch)
377 }
378
379 pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
383 let commit = self
384 .inner
385 .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
386 .map_err(|e| e.to_string())?;
387 let new_tree = commit.tree().map_err(|e| e.to_string())?;
388 let old_tree = match commit.parent(0) {
389 Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
390 Err(_) => None,
392 };
393 let mut opts = git2::DiffOptions::new();
394 opts.context_lines(3)
395 .pathspec(path)
396 .include_unmodified(false);
397 let diff = self
398 .inner
399 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
400 .map_err(|e| e.to_string())?;
401 let mut file = None;
402 for (d, _delta) in diff.deltas().enumerate() {
403 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
404 continue; };
406 let hunks = hunks_from_patch(&patch);
407 let added = hunks
408 .iter()
409 .flat_map(|h| &h.lines)
410 .filter(|l| l.origin == LineOrigin::Addition)
411 .count();
412 let deleted = hunks
413 .iter()
414 .flat_map(|h| &h.lines)
415 .filter(|l| l.origin == LineOrigin::Deletion)
416 .count();
417 file = Some(FileDiff {
418 path: path.to_path_buf(),
419 hunks,
420 added,
421 deleted,
422 });
423 }
424 file.ok_or_else(|| "no diff for path".to_string())
425 }
426
427 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
431 let patch = hunk.to_patch(rel);
432 let mut child = std::process::Command::new("git")
433 .args([
434 "-C",
435 &self.workdir.display().to_string(),
436 "apply",
437 "--cached",
438 "--unidiff-zero",
439 ])
440 .stdin(std::process::Stdio::piped())
441 .stdout(std::process::Stdio::null())
442 .stderr(std::process::Stdio::piped())
443 .spawn()
444 .map_err(|e| format!("spawn git: {e}"))?;
445 use std::io::Write;
446 child
447 .stdin
448 .as_mut()
449 .expect("piped")
450 .write_all(patch.as_bytes())
451 .map_err(|e| e.to_string())?;
452 let out = child.wait_with_output().map_err(|e| e.to_string())?;
453 if out.status.success() {
454 Ok(())
455 } else {
456 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
457 }
458 }
459}
460
461fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
464 let mut hunks = Vec::new();
465 for h in 0..patch.num_hunks() {
466 let Ok((header, line_count)) = patch.hunk(h) else {
467 continue;
468 };
469 let mut lines = Vec::with_capacity(line_count);
470 for l in 0..line_count {
471 let Ok(line) = patch.line_in_hunk(h, l) else {
472 continue;
473 };
474 let origin = match line.origin() {
475 '+' => LineOrigin::Addition,
476 '-' => LineOrigin::Deletion,
477 _ => LineOrigin::Context,
478 };
479 let old_lineno = line.old_lineno().map(|n| n as usize);
481 let new_lineno = line.new_lineno().map(|n| n as usize);
482 let text = String::from_utf8_lossy(line.content())
483 .trim_end_matches('\n')
484 .to_string();
485 lines.push(DiffLine {
486 origin,
487 old_lineno,
488 new_lineno,
489 text,
490 });
491 }
492 hunks.push(Hunk::build(
493 header.old_start() as usize,
494 header.old_lines() as usize,
495 header.new_start() as usize,
496 header.new_lines() as usize,
497 lines,
498 ));
499 }
500 hunks
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct SourceLocation {
508 pub revision: GitRevision,
509 pub path: PathBuf,
511 pub lines: Option<(usize, usize)>,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub enum GitRevision {
517 Head,
519 Commit(String),
521}
522
523impl SourceLocation {
524 pub fn revision_slug(&self) -> String {
526 match &self.revision {
527 GitRevision::Head => "HEAD".into(),
528 GitRevision::Commit(sha) => sha.clone(),
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use std::process::Command;
537
538 pub(crate) fn git(root: &std::path::Path, args: &[&str]) {
539 Command::new("git")
540 .args(args)
541 .current_dir(root)
542 .output()
543 .unwrap();
544 }
545
546 pub(crate) fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
547 let dir = tempfile::tempdir().unwrap();
548 let root = dir.path();
549 git(root, &["init", "-q"]);
550 git(root, &["config", "user.email", "t@t.t"]);
551 git(root, &["config", "user.name", "t"]);
552 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
553 git(root, &["add", "."]);
554 git(root, &["commit", "-qm", "init"]);
555 let repo = Repo::discover(root).unwrap();
556 let file = root.join("f.rs");
557 (dir, repo, file)
558 }
559
560 #[test]
561 fn clean_buffer_has_no_hunks() {
562 let (_d, repo, path) = fixture();
563 let content = repo.head_content(&path).unwrap();
564 assert!(repo.hunks(&path, &content).is_empty());
565 }
566
567 #[test]
568 fn change_and_add_and_delete() {
569 let (_d, repo, path) = fixture();
570 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
571 let hunks = repo.hunks(&path, edited);
572 assert_eq!(hunks.len(), 1);
573 assert_eq!(hunks[0].kind, HunkKind::Change);
574 assert!(hunks[0].covers(2, 4));
575 assert!(hunks[0].covers(4, 4));
576 assert!(!hunks[0].covers(1, 4));
577 assert!(hunks[0]
578 .lines
579 .iter()
580 .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d")));
581 }
582
583 #[test]
586 fn line_numbers_track_both_sides() {
587 let (_d, repo, path) = fixture();
588 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
589 let hunks = repo.hunks(&path, edited);
590 assert_eq!(hunks.len(), 1);
591 let h = &hunks[0];
592 let ctx = h
593 .lines
594 .iter()
595 .find(|l| l.origin == LineOrigin::Context)
596 .unwrap();
597 assert_eq!(
598 (ctx.old_lineno, ctx.new_lineno),
599 (Some(1), Some(1)),
600 "context lines carry both numbers, 1-based"
601 );
602 let add = h
603 .lines
604 .iter()
605 .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d"))
606 .unwrap();
607 assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
608 let del = h
609 .lines
610 .iter()
611 .find(|l| l.origin == LineOrigin::Deletion)
612 .unwrap();
613 assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
614 }
615
616 #[test]
617 fn pure_delete_marks_following_line() {
618 let (_d, repo, path) = fixture();
619 let edited = "fn a() {}\nfn c() {}\n";
620 let hunks = repo.hunks(&path, edited);
621 assert_eq!(hunks.len(), 1);
622 assert_eq!(hunks[0].kind, HunkKind::Delete);
623 assert!(hunks[0].covers(2, 4)); }
625
626 #[test]
627 fn stage_hunk_applies_to_index() {
628 let (_d, repo, path) = fixture();
629 let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
630 let hunks = repo.hunks(&path, edited);
631 assert_eq!(hunks.len(), 1);
632 assert_eq!(hunks[0].kind, HunkKind::Add);
633 let root = repo.workdir.clone();
634 repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
635 let out = Command::new("git")
636 .args([
637 "-C",
638 &root.display().to_string(),
639 "diff",
640 "--cached",
641 "--stat",
642 ])
643 .output()
644 .unwrap();
645 let stat = String::from_utf8_lossy(&out.stdout);
646 assert!(stat.contains("f.rs"), "{stat}");
647 }
648
649 #[test]
652 fn to_patch_is_applyable() {
653 let (_d, repo, path) = fixture();
654 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
655 let hunks = repo.hunks(&path, edited);
656 assert_eq!(hunks.len(), 1);
657 let patch = hunks[0].to_patch(Path::new("f.rs"));
658 assert!(
659 patch.starts_with("--- a/f.rs\n+++ b/f.rs\n@@ -1,3 +1,3 @@\n"),
660 "{patch}"
661 );
662 assert!(patch.contains("-fn b() {}\n+fn b2() {}\n"), "{patch}");
663 assert!(patch.ends_with(" fn c() {}\n"), "{patch}");
664 }
665
666 #[test]
669 fn commit_file_diff_is_structured() {
670 let (_d, repo, path) = fixture();
671 let root = repo.workdir.clone();
672 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
673 git(&root, &["add", "."]);
674 git(&root, &["commit", "-qm", "change b"]);
675 let sha = String::from_utf8_lossy(
676 &Command::new("git")
677 .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
678 .output()
679 .unwrap()
680 .stdout,
681 )
682 .trim()
683 .to_string();
684 let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
685 assert_eq!(diff.added, 1);
686 assert_eq!(diff.deleted, 1);
687 assert_eq!(diff.hunks.len(), 1);
688 assert_eq!(diff.hunks[0].kind, HunkKind::Change);
689 assert!(diff.hunks[0].lines.iter().any(|l| l.text == "fn b2() {}"));
690 let _ = path;
691 }
692
693 #[test]
696 fn commit_file_diff_root_commit() {
697 let (_d, repo, _path) = fixture();
698 let root = repo.workdir.clone();
699 let sha = String::from_utf8_lossy(
700 &Command::new("git")
701 .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
702 .output()
703 .unwrap()
704 .stdout,
705 )
706 .trim()
707 .to_string();
708 let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
709 assert_eq!(diff.added, 3);
710 assert_eq!(diff.deleted, 0);
711 assert!(diff
712 .hunks
713 .iter()
714 .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
715 }
716}
717
718#[cfg(test)]
719mod head_tests {
720 use super::tests::fixture;
721 use super::*;
722 use std::process::Command;
723
724 #[test]
725 fn head_content_probe() {
726 let dir = tempfile::tempdir().unwrap();
727 let root = dir.path();
728 let git = |args: &[&str]| {
729 Command::new("git")
730 .args(args)
731 .current_dir(root)
732 .output()
733 .unwrap();
734 };
735 git(&["init", "-q"]);
736 git(&["config", "user.email", "t@t.t"]);
737 git(&["config", "user.name", "t"]);
738 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
739 git(&["add", "."]);
740 git(&["commit", "-qm", "init"]);
741 let repo = Repo::discover(root).unwrap();
742 eprintln!("workdir: {:?}", repo.workdir());
743 let abs = root.join("f.rs");
744 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
745 eprintln!("head: {:?}", repo.head_content(&abs));
746 assert!(repo.head_content(&abs).is_some());
747 }
748
749 #[test]
751 fn four_state_edges() {
752 let (_d, repo, path) = fixture();
753 std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
755 let staged = repo.unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap());
756 assert_eq!(staged.len(), 1);
757 let hunk = staged.into_iter().next().unwrap();
758 repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
759 let idx = repo.index_content(&path).unwrap();
761 assert!(idx.contains("STAGED"));
762 let head = repo.head_content(&path).unwrap();
763 assert!(!head.contains("STAGED"));
764 assert_eq!(repo.staged_hunks(&path).len(), 1);
766 let wt = std::fs::read_to_string(&path).unwrap();
767 assert!(repo.unstaged_hunks(&path, &wt).is_empty());
768 let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
770 let unstaged = repo.unstaged_hunks(&path, live);
771 assert_eq!(unstaged.len(), 1);
772 assert!(unstaged[0]
773 .lines
774 .iter()
775 .any(|l| l.text.starts_with("fn live")));
776 assert_eq!(repo.staged_hunks(&path).len(), 1, "staged untouched");
777 let staged = repo.staged_hunks(&path);
779 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
780 assert!(repo.staged_hunks(&path).is_empty());
781 assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
782 }
783}