1#![forbid(unsafe_code)]
15#![deny(missing_docs)]
16#![warn(clippy::pedantic)]
17
18use std::path::Path;
19use std::process::Command;
20
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23pub enum Change {
24 Added,
26 Modified,
28 Deleted,
30 Renamed,
32 Untracked,
34 Conflicted,
36}
37
38impl Change {
39 #[must_use]
41 pub fn letter(self) -> char {
42 match self {
43 Change::Added => 'A',
44 Change::Modified => 'M',
45 Change::Deleted => 'D',
46 Change::Renamed => 'R',
47 Change::Untracked => '?',
48 Change::Conflicted => 'U',
49 }
50 }
51
52 fn from_code(c: char) -> Option<Change> {
53 match c {
54 'A' => Some(Change::Added),
55 'M' | 'C' => Some(Change::Modified),
57 'D' => Some(Change::Deleted),
58 'R' => Some(Change::Renamed),
59 '?' => Some(Change::Untracked),
60 'U' => Some(Change::Conflicted),
61 _ => None,
62 }
63 }
64}
65
66#[derive(Clone, PartialEq, Eq, Debug)]
69pub struct FileStatus {
70 pub path: String,
72 pub staged: Option<Change>,
74 pub unstaged: Option<Change>,
76}
77
78impl FileStatus {
79 #[must_use]
81 pub fn is_staged(&self) -> bool {
82 self.staged.is_some()
83 }
84
85 #[must_use]
88 pub fn primary(&self) -> Option<Change> {
89 self.staged.or(self.unstaged)
90 }
91}
92
93#[must_use]
99pub fn parse_status(output: &str) -> Vec<FileStatus> {
100 let mut out = Vec::new();
101 for line in output.lines() {
102 if line.len() < 3 {
103 continue;
104 }
105 let bytes = line.as_bytes();
106 let x = bytes[0] as char;
107 let y = bytes[1] as char;
108 let rest = line[3..].trim_end();
109 let path = match rest.rsplit_once(" -> ") {
111 Some((_, new)) => new.to_string(),
112 None => rest.to_string(),
113 };
114 if path.is_empty() {
115 continue;
116 }
117 let (staged, unstaged) = if x == '?' && y == '?' {
118 (None, Some(Change::Untracked))
119 } else {
120 (Change::from_code(x), Change::from_code(y))
121 };
122 out.push(FileStatus {
123 path,
124 staged,
125 unstaged,
126 });
127 }
128 out
129}
130
131#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133pub enum LineMark {
134 Added,
136 Modified,
138 Deleted,
140}
141
142#[must_use]
149pub fn diff_marks(head: &str, current: &str) -> Vec<(usize, LineMark)> {
150 use similar::{Algorithm, DiffOp, TextDiff};
151
152 let diff = TextDiff::configure()
153 .algorithm(Algorithm::Myers)
154 .diff_lines(head, current);
155 let current_lines = current.lines().count();
156 let mut marks = Vec::new();
157 for op in diff.ops() {
158 match *op {
159 DiffOp::Insert {
160 new_index, new_len, ..
161 } => {
162 for i in new_index..new_index + new_len {
163 marks.push((i, LineMark::Added));
164 }
165 }
166 DiffOp::Replace {
167 new_index, new_len, ..
168 } => {
169 for i in new_index..new_index + new_len {
170 marks.push((i, LineMark::Modified));
171 }
172 }
173 DiffOp::Delete { new_index, .. } => {
174 let at = new_index.min(current_lines.saturating_sub(1));
175 marks.push((at, LineMark::Deleted));
176 }
177 DiffOp::Equal { .. } => {}
178 }
179 }
180 marks
181}
182
183#[derive(Clone, PartialEq, Eq, Debug)]
186pub struct Hunk {
187 pub current_start: usize,
189 pub current_end: usize,
192 pub head_start: usize,
195 pub head_text: String,
198}
199
200impl Hunk {
201 #[must_use]
204 pub fn contains(&self, line: usize) -> bool {
205 if self.current_start == self.current_end {
206 line == self.current_start
207 } else {
208 line >= self.current_start && line < self.current_end
209 }
210 }
211}
212
213#[must_use]
217pub fn hunks(head: &str, current: &str) -> Vec<Hunk> {
218 use similar::{Algorithm, DiffOp, TextDiff};
219
220 let diff = TextDiff::configure()
221 .algorithm(Algorithm::Myers)
222 .diff_lines(head, current);
223 let head_lines: Vec<&str> = head.split_inclusive('\n').collect();
224 let mut out: Vec<Hunk> = Vec::new();
225 let mut run: Option<(usize, usize, usize, usize)> = None;
227
228 let flush = |run: &mut Option<(usize, usize, usize, usize)>, out: &mut Vec<Hunk>| {
229 if let Some((nlo, nhi, olo, ohi)) = run.take() {
230 let head_text = head_lines
231 .get(olo..ohi)
232 .map(<[&str]>::concat)
233 .unwrap_or_default();
234 out.push(Hunk {
235 current_start: nlo,
236 current_end: nhi,
237 head_start: olo,
238 head_text,
239 });
240 }
241 };
242
243 for op in diff.ops() {
244 let (ni, nl, oi, ol) = match *op {
245 DiffOp::Equal { .. } => {
246 flush(&mut run, &mut out);
247 continue;
248 }
249 DiffOp::Insert {
250 new_index,
251 new_len,
252 old_index,
253 ..
254 } => (new_index, new_len, old_index, 0),
255 DiffOp::Delete {
256 new_index,
257 old_index,
258 old_len,
259 ..
260 } => (new_index, 0, old_index, old_len),
261 DiffOp::Replace {
262 new_index,
263 new_len,
264 old_index,
265 old_len,
266 ..
267 } => (new_index, new_len, old_index, old_len),
268 };
269 run = Some(match run {
270 Some((nlo, nhi, olo, ohi)) => {
271 (nlo.min(ni), nhi.max(ni + nl), olo.min(oi), ohi.max(oi + ol))
272 }
273 None => (ni, ni + nl, oi, oi + ol),
274 });
275 }
276 flush(&mut run, &mut out);
277 out
278}
279
280#[derive(Clone, PartialEq, Eq, Debug, Default)]
283pub struct BlameLine {
284 pub hash: String,
286 pub author: String,
288 pub date: String,
290 pub summary: String,
292}
293
294impl BlameLine {
295 #[must_use]
297 pub fn is_uncommitted(&self) -> bool {
298 self.hash.chars().all(|c| c == '0')
299 }
300}
301
302#[must_use]
305pub fn parse_blame_porcelain(output: &str) -> Option<BlameLine> {
306 let mut hash = None;
307 let mut author = String::new();
308 let mut summary = String::new();
309 let mut time: i64 = 0;
310 let mut tz: i32 = 0;
311 for line in output.lines() {
312 if hash.is_none() {
313 if let Some(h) = line.split(' ').next().filter(|h| h.len() == 40) {
315 hash = Some(h.chars().take(8).collect::<String>());
316 }
317 continue;
318 }
319 if let Some(name) = line.strip_prefix("author ") {
320 author = name.to_string();
321 } else if let Some(secs) = line.strip_prefix("author-time ") {
322 time = secs.trim().parse().unwrap_or(0);
323 } else if let Some(off) = line.strip_prefix("author-tz ") {
324 tz = parse_tz_offset(off.trim());
325 } else if let Some(s) = line.strip_prefix("summary ") {
326 summary = s.to_string();
327 }
328 }
329 let hash = hash?;
330 Some(BlameLine {
331 hash,
332 author,
333 date: epoch_to_date(time, tz),
334 summary,
335 })
336}
337
338fn parse_tz_offset(tz: &str) -> i32 {
340 let sign = if tz.starts_with('-') { -1 } else { 1 };
341 let digits: String = tz.chars().filter(char::is_ascii_digit).collect();
342 if digits.len() < 4 {
343 return 0;
344 }
345 let hours: i32 = digits[0..2].parse().unwrap_or(0);
346 let mins: i32 = digits[2..4].parse().unwrap_or(0);
347 sign * (hours * 3600 + mins * 60)
348}
349
350#[must_use]
353fn epoch_to_date(secs: i64, tz_offset: i32) -> String {
354 let days = (secs + i64::from(tz_offset)).div_euclid(86_400);
355 let z = days + 719_468;
356 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
357 let doe = z - era * 146_097;
358 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
359 let y = yoe + era * 400;
360 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
361 let mp = (5 * doy + 2) / 153;
362 let d = doy - (153 * mp + 2) / 5 + 1;
363 let m = if mp < 10 { mp + 3 } else { mp - 9 };
364 let y = y + i64::from(m <= 2);
365 format!("{y:04}-{m:02}-{d:02}")
366}
367
368#[must_use]
371pub fn blame_line(dir: &Path, rel_path: &str, line: usize) -> Option<BlameLine> {
372 let spec = format!("{line},{line}");
373 let out = git_stdout(
374 dir,
375 &["blame", "--line-porcelain", "-L", &spec, "--", rel_path],
376 )?;
377 parse_blame_porcelain(&out)
378}
379
380fn git(dir: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
383 Command::new("git").current_dir(dir).args(args).output()
384}
385
386fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
388 let out = git(dir, args).ok()?;
389 if out.status.success() {
390 Some(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
391 } else {
392 None
393 }
394}
395
396#[must_use]
398pub fn is_repo(dir: &Path) -> bool {
399 git_stdout(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
400}
401
402#[must_use]
405pub fn branch(dir: &Path) -> Option<String> {
406 let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
407 if name == "HEAD" {
408 git_stdout(dir, &["rev-parse", "--short", "HEAD"])
410 } else {
411 Some(name)
412 }
413}
414
415#[must_use]
417pub fn is_dirty(dir: &Path) -> bool {
418 git_stdout(dir, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
419}
420
421#[must_use]
423pub fn status(dir: &Path) -> Vec<FileStatus> {
424 git_stdout(dir, &["status", "--porcelain"])
425 .map(|s| parse_status(&s))
426 .unwrap_or_default()
427}
428
429#[must_use]
432pub fn head_blob(dir: &Path, rel_path: &str) -> Option<String> {
433 let out = git(dir, &["show", &format!("HEAD:{rel_path}")]).ok()?;
436 out.status
437 .success()
438 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
439}
440
441#[must_use]
443pub fn stage(dir: &Path, rel_path: &str) -> bool {
444 git(dir, &["add", "--", rel_path]).is_ok_and(|o| o.status.success())
445}
446
447#[must_use]
450pub fn index_blob(dir: &Path, rel_path: &str) -> Option<String> {
451 let out = git(dir, &["show", &format!(":{rel_path}")]).ok()?;
452 out.status
453 .success()
454 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
455}
456
457pub fn stage_content(dir: &Path, rel_path: &str, content: &str) -> Result<(), String> {
464 let hash = git_stdin(
465 dir,
466 &["hash-object", "-w", "--path", rel_path, "--stdin"],
467 content,
468 )?;
469 let sha = hash.trim();
470 let mode = git_stdout(dir, &["ls-files", "-s", "--", rel_path])
473 .and_then(|s| s.split_whitespace().next().map(str::to_string))
474 .unwrap_or_else(|| "100644".to_string());
475 let info = format!("{mode},{sha},{rel_path}");
476 let out =
477 git(dir, &["update-index", "--add", "--cacheinfo", &info]).map_err(|e| e.to_string())?;
478 if out.status.success() {
479 Ok(())
480 } else {
481 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
482 }
483}
484
485fn git_stdin(dir: &Path, args: &[&str], input: &str) -> Result<String, String> {
487 use std::io::Write;
488 use std::process::Stdio;
489 let mut child = Command::new("git")
490 .current_dir(dir)
491 .args(args)
492 .stdin(Stdio::piped())
493 .stdout(Stdio::piped())
494 .stderr(Stdio::piped())
495 .spawn()
496 .map_err(|e| e.to_string())?;
497 child
498 .stdin
499 .take()
500 .ok_or_else(|| "no stdin".to_string())?
501 .write_all(input.as_bytes())
502 .map_err(|e| e.to_string())?;
503 let out = child.wait_with_output().map_err(|e| e.to_string())?;
504 if out.status.success() {
505 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
506 } else {
507 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
508 }
509}
510
511#[must_use]
513pub fn unstage(dir: &Path, rel_path: &str) -> bool {
514 git(dir, &["restore", "--staged", "--", rel_path]).is_ok_and(|o| o.status.success())
515}
516
517#[must_use]
520pub fn commit_count(dir: &Path) -> Option<u64> {
521 git_stdout(dir, &["rev-list", "--count", "HEAD"])?
522 .trim()
523 .parse()
524 .ok()
525}
526
527#[must_use]
529pub fn local_branches(dir: &Path) -> Vec<String> {
530 let out = git_stdout(dir, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
531 let mut names: Vec<String> = out
532 .lines()
533 .map(str::trim)
534 .filter(|s| !s.is_empty())
535 .map(String::from)
536 .collect();
537 if let Some(cur) = branch(dir)
538 && let Some(pos) = names.iter().position(|n| *n == cur)
539 {
540 names.swap(0, pos);
541 }
542 names
543}
544
545pub fn checkout(dir: &Path, branch: &str) -> Result<(), String> {
551 match git(dir, &["switch", branch]) {
552 Ok(o) if o.status.success() => Ok(()),
553 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
554 Err(e) => Err(e.to_string()),
555 }
556}
557
558pub fn create_branch(dir: &Path, name: &str) -> Result<(), String> {
565 match git(dir, &["switch", "-c", name]) {
566 Ok(o) if o.status.success() => Ok(()),
567 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
568 Err(e) => Err(e.to_string()),
569 }
570}
571
572pub fn commit(dir: &Path, message: &str) -> Result<(), String> {
578 run_ok(dir, &["commit", "-m", message])
579}
580
581pub fn commit_amend(dir: &Path) -> Result<(), String> {
587 run_ok(dir, &["commit", "--amend", "--no-edit"])
588}
589
590pub fn stash_push(dir: &Path) -> Result<(), String> {
595 run_ok(dir, &["stash", "push"])
596}
597
598pub fn stash_pop(dir: &Path) -> Result<(), String> {
603 run_ok(dir, &["stash", "pop"])
604}
605
606fn run_ok(dir: &Path, args: &[&str]) -> Result<(), String> {
608 match git(dir, args) {
609 Ok(o) if o.status.success() => Ok(()),
610 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
611 Err(e) => Err(e.to_string()),
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn hunks_groups_a_modified_line() {
621 let h = hunks("a\nb\nc\n", "a\nB\nc\n");
622 assert_eq!(h.len(), 1);
623 assert_eq!(h[0].current_start, 1);
624 assert_eq!(h[0].current_end, 2);
625 assert_eq!(h[0].head_text, "b\n");
626 assert!(h[0].contains(1));
627 assert!(!h[0].contains(0));
628 }
629
630 #[test]
631 fn hunks_addition_has_empty_head_text() {
632 let h = hunks("a\nc\n", "a\nb1\nb2\nc\n");
633 assert_eq!(h.len(), 1);
634 assert_eq!((h[0].current_start, h[0].current_end), (1, 3));
635 assert_eq!(h[0].head_text, ""); }
637
638 #[test]
639 fn hunks_deletion_anchors_a_zero_width_range() {
640 let h = hunks("a\nb\nc\n", "a\nc\n");
641 assert_eq!(h.len(), 1);
642 assert_eq!(h[0].current_start, h[0].current_end); assert_eq!(h[0].head_text, "b\n"); assert!(h[0].contains(h[0].current_start));
645 }
646
647 #[test]
648 fn hunks_finds_multiple_regions() {
649 let h = hunks("a\nb\nc\nd\ne\n", "A\nb\nc\nD\ne\n");
650 assert_eq!(h.len(), 2);
651 assert_eq!(h[0].current_start, 0);
652 assert_eq!(h[1].current_start, 3);
653 }
654
655 #[test]
656 fn hunks_identical_text_is_empty() {
657 assert!(hunks("x\ny\n", "x\ny\n").is_empty());
658 }
659
660 #[test]
661 fn parse_status_reads_xy_codes_and_paths() {
662 let out = " M src/app.rs\nA src/new.rs\n?? notes.txt\nMM both.rs\nD gone.rs\n";
663 let rows = parse_status(out);
664 assert_eq!(rows.len(), 5);
665
666 assert_eq!(rows[0].path, "src/app.rs");
667 assert_eq!(rows[0].staged, None);
668 assert_eq!(rows[0].unstaged, Some(Change::Modified));
669
670 assert_eq!(rows[1].path, "src/new.rs");
671 assert_eq!(rows[1].staged, Some(Change::Added));
672 assert_eq!(rows[1].unstaged, None);
673
674 assert_eq!(rows[2].path, "notes.txt");
675 assert_eq!(rows[2].primary(), Some(Change::Untracked));
676
677 assert_eq!(rows[3].staged, Some(Change::Modified));
678 assert_eq!(rows[3].unstaged, Some(Change::Modified));
679
680 assert_eq!(rows[4].path, "gone.rs");
681 assert_eq!(rows[4].staged, Some(Change::Deleted));
682 }
683
684 #[test]
685 fn parse_status_records_rename_destination() {
686 let rows = parse_status("R old/name.rs -> new/name.rs\n");
687 assert_eq!(rows.len(), 1);
688 assert_eq!(rows[0].path, "new/name.rs");
689 assert_eq!(rows[0].staged, Some(Change::Renamed));
690 }
691
692 #[test]
693 fn parse_status_skips_blank_and_short_lines() {
694 assert!(parse_status("\n\nx\n").is_empty());
695 }
696
697 #[test]
698 fn diff_marks_flags_added_modified_and_deleted() {
699 let head = "alpha\nbeta\ngamma\n";
700 let current = "alpha\nBETA\ngamma\ndelta\n";
702 let marks = diff_marks(head, current);
703 assert!(
704 marks.contains(&(1, LineMark::Modified)),
705 "line 2 modified: {marks:?}"
706 );
707 assert!(
708 marks.contains(&(3, LineMark::Added)),
709 "line 4 added: {marks:?}"
710 );
711 }
712
713 #[test]
714 fn diff_marks_flags_deletion() {
715 let head = "a\nb\nc\n";
716 let current = "a\nc\n"; let marks = diff_marks(head, current);
718 assert!(
719 marks.iter().any(|&(_, m)| m == LineMark::Deleted),
720 "a deletion is marked: {marks:?}"
721 );
722 }
723
724 #[test]
725 fn diff_marks_empty_when_identical() {
726 assert!(diff_marks("x\ny\n", "x\ny\n").is_empty());
727 }
728
729 #[test]
730 fn parse_blame_porcelain_extracts_fields() {
731 let out = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 12 12 1\n\
732 author Ada Lovelace\n\
733 author-mail <ada@example.com>\n\
734 author-time 1700000000\n\
735 author-tz +0000\n\
736 committer Ada Lovelace\n\
737 summary Add the analytical engine\n\
738 filename engine.rs\n\
739 \tlet x = 1;\n";
740 let b = parse_blame_porcelain(out).expect("parsed");
741 assert_eq!(b.hash, "1a2b3c4d");
742 assert_eq!(b.author, "Ada Lovelace");
743 assert_eq!(b.date, "2023-11-14");
744 assert_eq!(b.summary, "Add the analytical engine");
745 assert!(!b.is_uncommitted());
746 }
747
748 #[test]
749 fn parse_blame_porcelain_flags_uncommitted() {
750 let out = "0000000000000000000000000000000000000000 1 1 1\n\
751 author Not Committed Yet\n\
752 author-time 1700000000\n\
753 author-tz +0000\n\
754 summary Version of engine.rs from engine.rs\n\
755 \tlet y = 2;\n";
756 let b = parse_blame_porcelain(out).expect("parsed");
757 assert!(b.is_uncommitted());
758 assert_eq!(b.author, "Not Committed Yet");
759 }
760
761 #[test]
762 fn parse_blame_porcelain_none_without_header() {
763 assert!(parse_blame_porcelain("not a blame\n").is_none());
764 }
765
766 #[test]
767 fn epoch_to_date_applies_tz_offset() {
768 assert_eq!(epoch_to_date(1_700_000_000, 0), "2023-11-14");
770 assert_eq!(epoch_to_date(1_700_000_000, 2 * 3600), "2023-11-15");
771 assert_eq!(epoch_to_date(0, 0), "1970-01-01");
772 }
773
774 #[test]
775 fn change_letters() {
776 assert_eq!(Change::Modified.letter(), 'M');
777 assert_eq!(Change::Untracked.letter(), '?');
778 }
779
780 #[test]
783 #[ignore = "needs git and an in-tree checkout"]
784 fn runners_report_branch_in_this_repo() {
785 let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
786 assert!(is_repo(dir));
787 assert!(branch(dir).is_some());
788 }
789}