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: Vec<u8>,
35 pub has_newline: bool,
38}
39
40impl DiffLine {
41 pub fn text_str(&self) -> std::borrow::Cow<'_, str> {
43 String::from_utf8_lossy(&self.text)
44 }
45 pub fn bytes_with_terminator(&self) -> Vec<u8> {
47 let mut b = self.text.clone();
48 if self.has_newline {
49 b.push(b'\n');
50 }
51 b
52 }
53}
54
55#[derive(Debug, Clone)]
57pub struct Hunk {
58 pub kind: HunkKind,
59 pub new_start: usize,
62 pub new_count: usize,
63 pub old_start: usize,
64 pub old_count: usize,
65 pub lines: Vec<DiffLine>,
66}
67
68#[derive(Debug, Clone)]
70pub struct FileDiff {
71 pub path: PathBuf,
72 pub hunks: Vec<Hunk>,
73 pub added: usize,
74 pub deleted: usize,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Sign {
81 AddOrChange,
83 DeleteAfter,
86}
87
88impl Hunk {
89 pub fn signs(&self) -> Vec<(usize, Sign)> {
91 let mut out = Vec::new();
92 let mut nl = self.new_start;
93 for line in &self.lines {
94 match line.origin {
95 LineOrigin::Addition => {
96 out.push((nl, Sign::AddOrChange));
97 nl += 1;
98 }
99 LineOrigin::Deletion => out.push((nl, Sign::DeleteAfter)),
100 LineOrigin::Context => nl += 1,
101 }
102 }
103 out
104 }
105
106 pub fn changed_region(&self) -> (usize, usize, usize, usize) {
111 let mut nl = self.new_start;
112 let mut ol = self.old_start;
113 let mut new_lines = Vec::new();
114 let mut old_lines = Vec::new();
115 for line in &self.lines {
116 match line.origin {
117 LineOrigin::Addition => {
118 new_lines.push(nl);
119 nl += 1;
120 }
121 LineOrigin::Deletion => {
122 old_lines.push(ol);
123 ol += 1;
124 }
125 LineOrigin::Context => {
126 nl += 1;
127 ol += 1;
128 }
129 }
130 }
131 let new_first = new_lines.first().copied().unwrap_or(nl);
132 let old_first = old_lines.first().copied().unwrap_or(ol);
133 (new_first, new_lines.len(), old_first, old_lines.len())
134 }
135
136 pub fn covers(&self, line_1based: usize, total_lines: usize) -> bool {
139 self.signs().iter().any(|&(l, kind)| match kind {
140 Sign::AddOrChange => l == line_1based,
141 Sign::DeleteAfter => l.min(total_lines) == line_1based,
142 })
143 }
144
145 pub fn header(&self) -> String {
147 format!(
148 "@@ -{},{} +{},{} @@",
149 self.old_start, self.old_count, self.new_start, self.new_count
150 )
151 }
152
153 pub fn build(
157 old_start: usize,
158 old_count: usize,
159 new_start: usize,
160 new_count: usize,
161 lines: Vec<DiffLine>,
162 ) -> Self {
163 let has_add = lines.iter().any(|l| l.origin == LineOrigin::Addition);
164 let has_del = lines.iter().any(|l| l.origin == LineOrigin::Deletion);
165 let kind = match (has_add, has_del) {
166 (true, false) => HunkKind::Add,
167 (false, true) => HunkKind::Delete,
168 _ => HunkKind::Change,
169 };
170 Hunk {
171 kind,
172 new_start,
173 new_count,
174 old_start,
175 old_count,
176 lines,
177 }
178 }
179}
180
181pub struct Repo {
182 inner: git2::Repository,
183 workdir: PathBuf,
184}
185
186impl Repo {
187 pub fn discover(from: &Path) -> Option<Self> {
189 let inner = git2::Repository::discover(from).ok()?;
190 let workdir = inner.workdir()?.to_path_buf();
191 Some(Self { inner, workdir })
192 }
193
194 pub fn workdir(&self) -> &Path {
195 &self.workdir
196 }
197
198 pub fn remotes(&self) -> Vec<(String, String)> {
200 let Ok(remotes) = self.inner.remotes() else {
201 return vec![];
202 };
203 remotes
204 .iter()
205 .flatten()
206 .filter_map(|name| {
207 self.inner
208 .find_remote(name)
209 .ok()
210 .and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
211 })
212 .collect()
213 }
214
215 pub fn head_sha(&self) -> Option<String> {
217 Some(
218 self.inner
219 .head()
220 .ok()?
221 .peel_to_commit()
222 .ok()?
223 .id()
224 .to_string(),
225 )
226 }
227
228 pub fn head_branch(&self) -> Option<String> {
230 self.inner
231 .head()
232 .ok()
233 .and_then(|h| h.shorthand().map(String::from))
234 }
235
236 fn rel_path(&self, path: &Path) -> Option<PathBuf> {
238 let abs = if path.is_absolute() {
239 path.to_path_buf()
240 } else {
241 self.workdir.join(path)
242 };
243 abs.strip_prefix(&self.workdir)
244 .ok()
245 .map(|p| p.to_path_buf())
246 }
247
248 pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
251 let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
252 let tree = commit.tree().ok()?;
253 let entry = tree.get_path(rel).ok()?;
254 let blob = self.inner.find_blob(entry.id()).ok()?;
255 Some(blob.content().to_vec())
256 }
257
258 pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
260 let oid = self.inner.revparse_single(sha).ok()?.id();
261 let commit = self.inner.find_commit(oid).ok()?;
262 let tree = commit.tree().ok()?;
263 let entry = tree.get_path(rel).ok()?;
264 let blob = self.inner.find_blob(entry.id()).ok()?;
265 Some(blob.content().to_vec())
266 }
267
268 pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
271 let mut index = self.inner.index().ok()?;
272 index.read(true).ok()?;
273 let entry = index.get_path(rel, 0)?;
274 let blob = self.inner.find_blob(entry.id).ok()?;
275 Some(blob.content().to_vec())
276 }
277
278 pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
280 let a = self.inner.revparse_single(a).ok()?.id();
281 let b = self.inner.revparse_single(b).ok()?.id();
282 let base = self.inner.merge_base(a, b).ok()?;
283 Some(base.to_string())
284 }
285
286 pub fn head_content(&self, path: &Path) -> Option<String> {
287 let rel = self.rel_path(path)?;
288 let head = self.inner.head().ok()?.peel_to_tree().ok()?;
289 let entry = head.get_path(&rel).ok()?;
290 let blob = self.inner.find_blob(entry.id()).ok()?;
291 String::from_utf8(blob.content().to_vec()).ok()
292 }
293
294 pub fn index_content(&self, path: &Path) -> Option<String> {
296 let rel = self.rel_path(path)?;
297 let mut index = self.inner.index().ok()?;
300 index.read(true).ok()?;
301 let entry = index.get_path(&rel, 0)?;
302 let blob = self.inner.find_blob(entry.id).ok()?;
303 String::from_utf8(blob.content().to_vec()).ok()
304 }
305
306 pub fn staged_hunks(&self, path: &Path) -> Vec<Hunk> {
310 let Some(rel) = self.rel_path(path) else {
311 return vec![];
312 };
313 let (Some(head), Some(index)) = (self.head_content(path), self.index_content(path)) else {
314 return vec![];
315 };
316 self.diff_strings(&head, &index, &rel)
317 }
318
319 pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
323 let Some(rel) = self.rel_path(path) else {
324 return vec![];
325 };
326 let base = self.index_content(path).or_else(|| self.head_content(path));
327 match base {
328 None => self.hunks(path, content), Some(base) => self.diff_strings(&base, content, &rel),
330 }
331 }
332
333 pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
336 let old_side: Vec<&DiffLine> = hunk
337 .lines
338 .iter()
339 .filter(|l| l.origin != LineOrigin::Addition)
340 .collect();
341 self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
342 }
343
344 pub fn hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
347 let Some(rel) = self.rel_path(path) else {
348 return vec![];
349 };
350 let old = self.head_content(path);
351 match old {
352 None => {
353 let count = content.lines().count();
354 if count == 0 {
355 return vec![];
356 }
357 vec![Hunk {
358 kind: HunkKind::Add,
359 new_start: 1,
360 new_count: count,
361 old_start: 0,
362 old_count: 0,
363 lines: split_lines_bytes(content.as_bytes())
364 .into_iter()
365 .enumerate()
366 .map(|(i, (text, has_newline))| DiffLine {
367 origin: LineOrigin::Addition,
368 old_lineno: None,
369 new_lineno: Some(i + 1),
370 text,
371 has_newline,
372 })
373 .collect(),
374 }]
375 }
376 Some(old) => self.diff_strings(&old, content, &rel),
377 }
378 }
379
380 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
381 let mut opts = git2::DiffOptions::new();
382 opts.context_lines(3);
383 let Ok(patch) = git2::Patch::from_buffers(
384 old.as_bytes(),
385 Some(rel),
386 new.as_bytes(),
387 Some(rel),
388 Some(&mut opts),
389 ) else {
390 return vec![];
391 };
392 hunks_from_patch(&patch)
393 }
394
395 pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
399 let commit = self
400 .inner
401 .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
402 .map_err(|e| e.to_string())?;
403 let new_tree = commit.tree().map_err(|e| e.to_string())?;
404 let old_tree = match commit.parent(0) {
405 Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
406 Err(_) => None,
408 };
409 let mut opts = git2::DiffOptions::new();
410 opts.context_lines(3)
411 .pathspec(path)
412 .include_unmodified(false);
413 let diff = self
414 .inner
415 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
416 .map_err(|e| e.to_string())?;
417 let mut file = None;
418 for (d, _delta) in diff.deltas().enumerate() {
419 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
420 continue; };
422 let hunks = hunks_from_patch(&patch);
423 let added = hunks
424 .iter()
425 .flat_map(|h| &h.lines)
426 .filter(|l| l.origin == LineOrigin::Addition)
427 .count();
428 let deleted = hunks
429 .iter()
430 .flat_map(|h| &h.lines)
431 .filter(|l| l.origin == LineOrigin::Deletion)
432 .count();
433 file = Some(FileDiff {
434 path: path.to_path_buf(),
435 hunks,
436 added,
437 deleted,
438 });
439 }
440 file.ok_or_else(|| "no diff for path".to_string())
441 }
442
443 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
449 let new_side: Vec<&DiffLine> = hunk
450 .lines
451 .iter()
452 .filter(|l| l.origin != LineOrigin::Deletion)
453 .collect();
454 self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
455 }
456
457 fn index_region_edit(
461 &self,
462 rel: &Path,
463 start: usize,
464 count: usize,
465 new_lines: &[&DiffLine],
466 ) -> Result<(), String> {
467 let mut index = self.inner.index().map_err(|e| e.to_string())?;
468 index.read(true).map_err(|e| e.to_string())?; let entry = index.get_path(rel, 0);
470 let (old_bytes, mode) = match entry {
471 Some(e) => {
472 let blob = self
473 .inner
474 .find_blob(e.id)
475 .map_err(|e| format!("index blob: {e}"))?;
476 (blob.content().to_vec(), e.mode)
477 }
478 None => (Vec::new(), 0o100644), };
480 let lines = split_lines_bytes(&old_bytes);
481 let lo = start.saturating_sub(1).min(lines.len());
482 let hi = (lo + count).min(lines.len());
483 let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
484 for (text, nl) in &lines[..lo] {
485 out.extend_from_slice(text);
486 if *nl {
487 out.push(b'\n');
488 }
489 }
490 for l in new_lines {
491 out.extend_from_slice(&l.bytes_with_terminator());
492 }
493 for (text, nl) in &lines[hi..] {
494 out.extend_from_slice(text);
495 if *nl {
496 out.push(b'\n');
497 }
498 }
499 let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
500 index
501 .add(&git2::IndexEntry {
502 ctime: git2::IndexTime::new(0, 0),
503 mtime: git2::IndexTime::new(0, 0),
504 dev: 0,
505 ino: 0,
506 mode,
507 uid: 0,
508 gid: 0,
509 file_size: 0,
510 id: oid,
511 flags: 0,
512 flags_extended: 0,
513 path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
514 })
515 .map_err(|e| e.to_string())?;
516 index.write().map_err(|e| e.to_string())?;
517 Ok(())
518 }
519}
520
521fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
525 let mut out = Vec::new();
526 let mut start = 0;
527 for (i, b) in bytes.iter().enumerate() {
528 if *b == b'\n' {
529 out.push((bytes[start..i].to_vec(), true));
530 start = i + 1;
531 }
532 }
533 if start < bytes.len() {
534 out.push((bytes[start..].to_vec(), false));
535 }
536 out
537}
538
539fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
542 let mut hunks = Vec::new();
543 for h in 0..patch.num_hunks() {
544 let Ok((header, line_count)) = patch.hunk(h) else {
545 continue;
546 };
547 let mut lines = Vec::with_capacity(line_count);
548 for l in 0..line_count {
549 let Ok(line) = patch.line_in_hunk(h, l) else {
550 continue;
551 };
552 let raw = line.content();
556 if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
557 continue;
558 }
559 let origin = match line.origin() {
560 '+' => LineOrigin::Addition,
561 '-' => LineOrigin::Deletion,
562 _ => LineOrigin::Context,
563 };
564 let old_lineno = line.old_lineno().map(|n| n as usize);
566 let new_lineno = line.new_lineno().map(|n| n as usize);
567 let content = line.content();
568 let (text, has_newline) = match content.last() {
569 Some(b'\n') => (&content[..content.len() - 1], true),
570 _ => (content, false),
571 };
572 lines.push(DiffLine {
573 origin,
574 old_lineno,
575 new_lineno,
576 text: text.to_vec(),
577 has_newline,
578 });
579 }
580 hunks.push(Hunk::build(
581 header.old_start() as usize,
582 header.old_lines() as usize,
583 header.new_start() as usize,
584 header.new_lines() as usize,
585 lines,
586 ));
587 }
588 hunks
589}
590
591#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct SourceLocation {
596 pub revision: GitRevision,
597 pub path: PathBuf,
599 pub lines: Option<(usize, usize)>,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum GitRevision {
605 Head,
607 Commit(String),
609 Index,
612 Worktree,
614 MergeBase(String, String),
616}
617
618impl GitRevision {
619 pub fn read(&self, repo: &Repo, rel: &Path) -> Option<Vec<u8>> {
622 match self {
623 GitRevision::Head => repo.head_bytes(rel),
624 GitRevision::Commit(sha) => repo.commit_bytes(sha, rel),
625 GitRevision::Index => repo.index_bytes(rel),
626 GitRevision::Worktree => std::fs::read(repo.workdir.join(rel)).ok(),
627 GitRevision::MergeBase(a, b) => {
628 let base = repo.merge_base(a, b)?;
629 repo.commit_bytes(&base, rel)
630 }
631 }
632 }
633}
634
635impl SourceLocation {
636 pub fn revision_slug(&self) -> String {
638 match &self.revision {
639 GitRevision::Head => "HEAD".into(),
640 GitRevision::Commit(sha) => sha.clone(),
641 GitRevision::Index => "index".into(),
642 GitRevision::Worktree => "worktree".into(),
643 GitRevision::MergeBase(a, b) => format!("{a}...{b}"),
644 }
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::process::Command;
652
653 pub(crate) fn git(root: &std::path::Path, args: &[&str]) {
654 Command::new("git")
655 .args(args)
656 .current_dir(root)
657 .output()
658 .unwrap();
659 }
660
661 pub(crate) fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
662 let dir = tempfile::tempdir().unwrap();
663 let root = dir.path();
664 git(root, &["init", "-q"]);
665 git(root, &["config", "user.email", "t@t.t"]);
666 git(root, &["config", "user.name", "t"]);
667 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
668 git(root, &["add", "."]);
669 git(root, &["commit", "-qm", "init"]);
670 let repo = Repo::discover(root).unwrap();
671 let file = root.join("f.rs");
672 (dir, repo, file)
673 }
674
675 #[test]
676 fn clean_buffer_has_no_hunks() {
677 let (_d, repo, path) = fixture();
678 let content = repo.head_content(&path).unwrap();
679 assert!(repo.hunks(&path, &content).is_empty());
680 }
681
682 #[test]
683 fn change_and_add_and_delete() {
684 let (_d, repo, path) = fixture();
685 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
686 let hunks = repo.hunks(&path, edited);
687 assert_eq!(hunks.len(), 1);
688 assert_eq!(hunks[0].kind, HunkKind::Change);
689 assert!(hunks[0].covers(2, 4));
690 assert!(hunks[0].covers(4, 4));
691 assert!(!hunks[0].covers(1, 4));
692 assert!(hunks[0]
693 .lines
694 .iter()
695 .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with(b"fn d")));
696 }
697
698 #[test]
701 fn line_numbers_track_both_sides() {
702 let (_d, repo, path) = fixture();
703 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
704 let hunks = repo.hunks(&path, edited);
705 assert_eq!(hunks.len(), 1);
706 let h = &hunks[0];
707 let ctx = h
708 .lines
709 .iter()
710 .find(|l| l.origin == LineOrigin::Context)
711 .unwrap();
712 assert_eq!(
713 (ctx.old_lineno, ctx.new_lineno),
714 (Some(1), Some(1)),
715 "context lines carry both numbers, 1-based"
716 );
717 let add = h
718 .lines
719 .iter()
720 .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with(b"fn d"))
721 .unwrap();
722 assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
723 let del = h
724 .lines
725 .iter()
726 .find(|l| l.origin == LineOrigin::Deletion)
727 .unwrap();
728 assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
729 }
730
731 #[test]
732 fn pure_delete_marks_following_line() {
733 let (_d, repo, path) = fixture();
734 let edited = "fn a() {}\nfn c() {}\n";
735 let hunks = repo.hunks(&path, edited);
736 assert_eq!(hunks.len(), 1);
737 assert_eq!(hunks[0].kind, HunkKind::Delete);
738 assert!(hunks[0].covers(2, 4)); }
740
741 #[test]
742 fn stage_hunk_applies_to_index() {
743 let (_d, repo, path) = fixture();
744 let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
745 let hunks = repo.hunks(&path, edited);
746 assert_eq!(hunks.len(), 1);
747 assert_eq!(hunks[0].kind, HunkKind::Add);
748 let root = repo.workdir.clone();
749 repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
750 let out = Command::new("git")
751 .args([
752 "-C",
753 &root.display().to_string(),
754 "diff",
755 "--cached",
756 "--stat",
757 ])
758 .output()
759 .unwrap();
760 let stat = String::from_utf8_lossy(&out.stdout);
761 assert!(stat.contains("f.rs"), "{stat}");
762 }
763
764 #[test]
769 fn stage_hunk_is_byte_precise() {
770 let (_d, repo, path) = fixture();
771 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
772 let hunks = repo.hunks(&path, edited);
773 assert_eq!(hunks.len(), 1);
774 repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
775 assert_eq!(
777 repo.index_content(&path).as_deref(),
778 Some("fn a() {}\nfn b2() {}\nfn c() {}\n")
779 );
780 assert_eq!(
781 repo.head_content(&path).as_deref(),
782 Some("fn a() {}\nfn b() {}\nfn c() {}\n")
783 );
784 let staged = repo.staged_hunks(&path);
786 assert_eq!(staged.len(), 1);
787 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
788 assert_eq!(
789 repo.index_content(&path).as_deref(),
790 Some("fn a() {}\nfn b() {}\nfn c() {}\n")
791 );
792 }
793
794 #[test]
795 fn stage_hunk_preserves_a_missing_final_newline() {
796 let (_d, repo, path) = fixture();
797 let edited = "fn a() {}\nfn b() {}\nfn c() {}";
799 let hunks = repo.hunks(&path, edited);
800 repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
801 assert_eq!(repo.index_content(&path).as_deref(), Some(edited));
802 let staged = repo.staged_hunks(&path);
803 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
804 assert_eq!(
805 repo.index_content(&path).as_deref(),
806 Some("fn a() {}\nfn b() {}\nfn c() {}\n"),
807 "unstage restores the newline-terminated HEAD text"
808 );
809 }
810
811 #[test]
814 fn commit_file_diff_is_structured() {
815 let (_d, repo, path) = fixture();
816 let root = repo.workdir.clone();
817 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
818 git(&root, &["add", "."]);
819 git(&root, &["commit", "-qm", "change b"]);
820 let sha = String::from_utf8_lossy(
821 &Command::new("git")
822 .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
823 .output()
824 .unwrap()
825 .stdout,
826 )
827 .trim()
828 .to_string();
829 let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
830 assert_eq!(diff.added, 1);
831 assert_eq!(diff.deleted, 1);
832 assert_eq!(diff.hunks.len(), 1);
833 assert_eq!(diff.hunks[0].kind, HunkKind::Change);
834 assert!(diff.hunks[0].lines.iter().any(|l| l.text == b"fn b2() {}"));
835 let _ = path;
836 }
837
838 #[test]
841 fn commit_file_diff_root_commit() {
842 let (_d, repo, _path) = fixture();
843 let root = repo.workdir.clone();
844 let sha = String::from_utf8_lossy(
845 &Command::new("git")
846 .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
847 .output()
848 .unwrap()
849 .stdout,
850 )
851 .trim()
852 .to_string();
853 let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
854 assert_eq!(diff.added, 3);
855 assert_eq!(diff.deleted, 0);
856 assert!(diff
857 .hunks
858 .iter()
859 .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
860 }
861}
862
863#[cfg(test)]
864mod head_tests {
865 use super::tests::fixture;
866 use super::*;
867 use std::process::Command;
868
869 #[test]
870 fn head_content_probe() {
871 let dir = tempfile::tempdir().unwrap();
872 let root = dir.path();
873 let git = |args: &[&str]| {
874 Command::new("git")
875 .args(args)
876 .current_dir(root)
877 .output()
878 .unwrap();
879 };
880 git(&["init", "-q"]);
881 git(&["config", "user.email", "t@t.t"]);
882 git(&["config", "user.name", "t"]);
883 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
884 git(&["add", "."]);
885 git(&["commit", "-qm", "init"]);
886 let repo = Repo::discover(root).unwrap();
887 eprintln!("workdir: {:?}", repo.workdir());
888 let abs = root.join("f.rs");
889 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
890 eprintln!("head: {:?}", repo.head_content(&abs));
891 assert!(repo.head_content(&abs).is_some());
892 }
893
894 #[test]
896 fn four_state_edges() {
897 let (_d, repo, path) = fixture();
898 std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
900 let staged = repo.unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap());
901 assert_eq!(staged.len(), 1);
902 let hunk = staged.into_iter().next().unwrap();
903 repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
904 let idx = repo.index_content(&path).unwrap();
906 assert!(idx.contains("STAGED"));
907 let head = repo.head_content(&path).unwrap();
908 assert!(!head.contains("STAGED"));
909 assert_eq!(repo.staged_hunks(&path).len(), 1);
911 let wt = std::fs::read_to_string(&path).unwrap();
912 assert!(repo.unstaged_hunks(&path, &wt).is_empty());
913 let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
915 let unstaged = repo.unstaged_hunks(&path, live);
916 assert_eq!(unstaged.len(), 1);
917 assert!(unstaged[0]
918 .lines
919 .iter()
920 .any(|l| l.text.starts_with(b"fn live")));
921 assert_eq!(repo.staged_hunks(&path).len(), 1, "staged untouched");
922 let staged = repo.staged_hunks(&path);
924 repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
925 assert!(repo.staged_hunks(&path).is_empty());
926 assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
927 }
928}