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 hunks(&self, path: &Path, content: &str) -> Vec<Hunk> {
261 let Some(rel) = self.rel_path(path) else {
262 return vec![];
263 };
264 let old = self.head_content(path);
265 match old {
266 None => {
267 let count = content.lines().count();
268 if count == 0 {
269 return vec![];
270 }
271 vec![Hunk {
272 kind: HunkKind::Add,
273 new_start: 1,
274 new_count: count,
275 old_start: 0,
276 old_count: 0,
277 lines: content
278 .lines()
279 .enumerate()
280 .map(|(i, l)| DiffLine {
281 origin: LineOrigin::Addition,
282 old_lineno: None,
283 new_lineno: Some(i + 1),
284 text: l.to_string(),
285 })
286 .collect(),
287 }]
288 }
289 Some(old) => self.diff_strings(&old, content, &rel),
290 }
291 }
292
293 fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Vec<Hunk> {
294 let mut opts = git2::DiffOptions::new();
295 opts.context_lines(3);
296 let Ok(patch) = git2::Patch::from_buffers(
297 old.as_bytes(),
298 Some(rel),
299 new.as_bytes(),
300 Some(rel),
301 Some(&mut opts),
302 ) else {
303 return vec![];
304 };
305 hunks_from_patch(&patch)
306 }
307
308 pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
312 let commit = self
313 .inner
314 .find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
315 .map_err(|e| e.to_string())?;
316 let new_tree = commit.tree().map_err(|e| e.to_string())?;
317 let old_tree = match commit.parent(0) {
318 Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
319 Err(_) => None,
321 };
322 let mut opts = git2::DiffOptions::new();
323 opts.context_lines(3)
324 .pathspec(path)
325 .include_unmodified(false);
326 let diff = self
327 .inner
328 .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
329 .map_err(|e| e.to_string())?;
330 let mut file = None;
331 for (d, _delta) in diff.deltas().enumerate() {
332 let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
333 continue; };
335 let hunks = hunks_from_patch(&patch);
336 let added = hunks
337 .iter()
338 .flat_map(|h| &h.lines)
339 .filter(|l| l.origin == LineOrigin::Addition)
340 .count();
341 let deleted = hunks
342 .iter()
343 .flat_map(|h| &h.lines)
344 .filter(|l| l.origin == LineOrigin::Deletion)
345 .count();
346 file = Some(FileDiff {
347 path: path.to_path_buf(),
348 hunks,
349 added,
350 deleted,
351 });
352 }
353 file.ok_or_else(|| "no diff for path".to_string())
354 }
355
356 pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
360 let patch = hunk.to_patch(rel);
361 let mut child = std::process::Command::new("git")
362 .args([
363 "-C",
364 &self.workdir.display().to_string(),
365 "apply",
366 "--cached",
367 "--unidiff-zero",
368 ])
369 .stdin(std::process::Stdio::piped())
370 .stdout(std::process::Stdio::null())
371 .stderr(std::process::Stdio::piped())
372 .spawn()
373 .map_err(|e| format!("spawn git: {e}"))?;
374 use std::io::Write;
375 child
376 .stdin
377 .as_mut()
378 .expect("piped")
379 .write_all(patch.as_bytes())
380 .map_err(|e| e.to_string())?;
381 let out = child.wait_with_output().map_err(|e| e.to_string())?;
382 if out.status.success() {
383 Ok(())
384 } else {
385 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
386 }
387 }
388}
389
390fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
393 let mut hunks = Vec::new();
394 for h in 0..patch.num_hunks() {
395 let Ok((header, line_count)) = patch.hunk(h) else {
396 continue;
397 };
398 let mut lines = Vec::with_capacity(line_count);
399 for l in 0..line_count {
400 let Ok(line) = patch.line_in_hunk(h, l) else {
401 continue;
402 };
403 let origin = match line.origin() {
404 '+' => LineOrigin::Addition,
405 '-' => LineOrigin::Deletion,
406 _ => LineOrigin::Context,
407 };
408 let old_lineno = line.old_lineno().map(|n| n as usize);
410 let new_lineno = line.new_lineno().map(|n| n as usize);
411 let text = String::from_utf8_lossy(line.content())
412 .trim_end_matches('\n')
413 .to_string();
414 lines.push(DiffLine {
415 origin,
416 old_lineno,
417 new_lineno,
418 text,
419 });
420 }
421 hunks.push(Hunk::build(
422 header.old_start() as usize,
423 header.old_lines() as usize,
424 header.new_start() as usize,
425 header.new_lines() as usize,
426 lines,
427 ));
428 }
429 hunks
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use std::process::Command;
436
437 fn git(root: &std::path::Path, args: &[&str]) {
438 Command::new("git")
439 .args(args)
440 .current_dir(root)
441 .output()
442 .unwrap();
443 }
444
445 fn fixture() -> (tempfile::TempDir, Repo, PathBuf) {
446 let dir = tempfile::tempdir().unwrap();
447 let root = dir.path();
448 git(root, &["init", "-q"]);
449 git(root, &["config", "user.email", "t@t.t"]);
450 git(root, &["config", "user.name", "t"]);
451 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
452 git(root, &["add", "."]);
453 git(root, &["commit", "-qm", "init"]);
454 let repo = Repo::discover(root).unwrap();
455 let file = root.join("f.rs");
456 (dir, repo, file)
457 }
458
459 #[test]
460 fn clean_buffer_has_no_hunks() {
461 let (_d, repo, path) = fixture();
462 let content = repo.head_content(&path).unwrap();
463 assert!(repo.hunks(&path, &content).is_empty());
464 }
465
466 #[test]
467 fn change_and_add_and_delete() {
468 let (_d, repo, path) = fixture();
469 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
470 let hunks = repo.hunks(&path, edited);
471 assert_eq!(hunks.len(), 1);
472 assert_eq!(hunks[0].kind, HunkKind::Change);
473 assert!(hunks[0].covers(2, 4));
474 assert!(hunks[0].covers(4, 4));
475 assert!(!hunks[0].covers(1, 4));
476 assert!(hunks[0]
477 .lines
478 .iter()
479 .any(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d")));
480 }
481
482 #[test]
485 fn line_numbers_track_both_sides() {
486 let (_d, repo, path) = fixture();
487 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n";
488 let hunks = repo.hunks(&path, edited);
489 assert_eq!(hunks.len(), 1);
490 let h = &hunks[0];
491 let ctx = h
492 .lines
493 .iter()
494 .find(|l| l.origin == LineOrigin::Context)
495 .unwrap();
496 assert_eq!(
497 (ctx.old_lineno, ctx.new_lineno),
498 (Some(1), Some(1)),
499 "context lines carry both numbers, 1-based"
500 );
501 let add = h
502 .lines
503 .iter()
504 .find(|l| l.origin == LineOrigin::Addition && l.text.starts_with("fn d"))
505 .unwrap();
506 assert_eq!((add.old_lineno, add.new_lineno), (None, Some(4)));
507 let del = h
508 .lines
509 .iter()
510 .find(|l| l.origin == LineOrigin::Deletion)
511 .unwrap();
512 assert_eq!((del.old_lineno, del.new_lineno), (Some(2), None));
513 }
514
515 #[test]
516 fn pure_delete_marks_following_line() {
517 let (_d, repo, path) = fixture();
518 let edited = "fn a() {}\nfn c() {}\n";
519 let hunks = repo.hunks(&path, edited);
520 assert_eq!(hunks.len(), 1);
521 assert_eq!(hunks[0].kind, HunkKind::Delete);
522 assert!(hunks[0].covers(2, 4)); }
524
525 #[test]
526 fn stage_hunk_applies_to_index() {
527 let (_d, repo, path) = fixture();
528 let edited = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n";
529 let hunks = repo.hunks(&path, edited);
530 assert_eq!(hunks.len(), 1);
531 assert_eq!(hunks[0].kind, HunkKind::Add);
532 let root = repo.workdir.clone();
533 repo.stage_hunk(Path::new("f.rs"), &hunks[0]).unwrap();
534 let out = Command::new("git")
535 .args([
536 "-C",
537 &root.display().to_string(),
538 "diff",
539 "--cached",
540 "--stat",
541 ])
542 .output()
543 .unwrap();
544 let stat = String::from_utf8_lossy(&out.stdout);
545 assert!(stat.contains("f.rs"), "{stat}");
546 }
547
548 #[test]
551 fn to_patch_is_applyable() {
552 let (_d, repo, path) = fixture();
553 let edited = "fn a() {}\nfn b2() {}\nfn c() {}\n";
554 let hunks = repo.hunks(&path, edited);
555 assert_eq!(hunks.len(), 1);
556 let patch = hunks[0].to_patch(Path::new("f.rs"));
557 assert!(
558 patch.starts_with("--- a/f.rs\n+++ b/f.rs\n@@ -1,3 +1,3 @@\n"),
559 "{patch}"
560 );
561 assert!(patch.contains("-fn b() {}\n+fn b2() {}\n"), "{patch}");
562 assert!(patch.ends_with(" fn c() {}\n"), "{patch}");
563 }
564
565 #[test]
568 fn commit_file_diff_is_structured() {
569 let (_d, repo, path) = fixture();
570 let root = repo.workdir.clone();
571 std::fs::write(root.join("f.rs"), "fn a() {}\nfn b2() {}\nfn c() {}\n").unwrap();
572 git(&root, &["add", "."]);
573 git(&root, &["commit", "-qm", "change b"]);
574 let sha = String::from_utf8_lossy(
575 &Command::new("git")
576 .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
577 .output()
578 .unwrap()
579 .stdout,
580 )
581 .trim()
582 .to_string();
583 let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
584 assert_eq!(diff.added, 1);
585 assert_eq!(diff.deleted, 1);
586 assert_eq!(diff.hunks.len(), 1);
587 assert_eq!(diff.hunks[0].kind, HunkKind::Change);
588 assert!(diff.hunks[0].lines.iter().any(|l| l.text == "fn b2() {}"));
589 let _ = path;
590 }
591
592 #[test]
595 fn commit_file_diff_root_commit() {
596 let (_d, repo, _path) = fixture();
597 let root = repo.workdir.clone();
598 let sha = String::from_utf8_lossy(
599 &Command::new("git")
600 .args(["-C", &root.display().to_string(), "rev-parse", "HEAD"])
601 .output()
602 .unwrap()
603 .stdout,
604 )
605 .trim()
606 .to_string();
607 let diff = repo.commit_file_diff(&sha, Path::new("f.rs")).unwrap();
608 assert_eq!(diff.added, 3);
609 assert_eq!(diff.deleted, 0);
610 assert!(diff
611 .hunks
612 .iter()
613 .all(|h| h.lines.iter().all(|l| l.old_lineno.is_none())));
614 }
615}
616
617#[cfg(test)]
618mod head_tests {
619 use super::*;
620 use std::process::Command;
621
622 #[test]
623 fn head_content_probe() {
624 let dir = tempfile::tempdir().unwrap();
625 let root = dir.path();
626 let git = |args: &[&str]| {
627 Command::new("git")
628 .args(args)
629 .current_dir(root)
630 .output()
631 .unwrap();
632 };
633 git(&["init", "-q"]);
634 git(&["config", "user.email", "t@t.t"]);
635 git(&["config", "user.name", "t"]);
636 std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
637 git(&["add", "."]);
638 git(&["commit", "-qm", "init"]);
639 let repo = Repo::discover(root).unwrap();
640 eprintln!("workdir: {:?}", repo.workdir());
641 let abs = root.join("f.rs");
642 eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
643 eprintln!("head: {:?}", repo.head_content(&abs));
644 assert!(repo.head_content(&abs).is_some());
645 }
646}