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