1#![forbid(unsafe_code)]
15#![deny(missing_docs)]
16#![warn(clippy::pedantic)]
17
18use std::ffi::OsString;
19use std::path::Path;
20use std::process::Command;
21use std::sync::OnceLock;
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum Change {
26 Added,
28 Modified,
30 Deleted,
32 Renamed,
34 Untracked,
36 Conflicted,
38}
39
40impl Change {
41 #[must_use]
43 pub fn letter(self) -> char {
44 match self {
45 Change::Added => 'A',
46 Change::Modified => 'M',
47 Change::Deleted => 'D',
48 Change::Renamed => 'R',
49 Change::Untracked => '?',
50 Change::Conflicted => 'U',
51 }
52 }
53
54 fn from_code(c: char) -> Option<Change> {
55 match c {
56 'A' => Some(Change::Added),
57 'M' | 'C' => Some(Change::Modified),
59 'D' => Some(Change::Deleted),
60 'R' => Some(Change::Renamed),
61 '?' => Some(Change::Untracked),
62 'U' => Some(Change::Conflicted),
63 _ => None,
64 }
65 }
66}
67
68#[derive(Clone, PartialEq, Eq, Debug)]
71pub struct FileStatus {
72 pub path: String,
74 pub staged: Option<Change>,
76 pub unstaged: Option<Change>,
78}
79
80impl FileStatus {
81 #[must_use]
83 pub fn is_staged(&self) -> bool {
84 self.staged.is_some()
85 }
86
87 #[must_use]
90 pub fn primary(&self) -> Option<Change> {
91 self.staged.or(self.unstaged)
92 }
93}
94
95#[must_use]
101pub fn parse_status(output: &str) -> Vec<FileStatus> {
102 let mut out = Vec::new();
103 for line in output.lines() {
104 if line.len() < 3 {
105 continue;
106 }
107 let bytes = line.as_bytes();
108 let x = bytes[0] as char;
109 let y = bytes[1] as char;
110 let rest = line[3..].trim_end();
111 let path = match rest.rsplit_once(" -> ") {
113 Some((_, new)) => new.to_string(),
114 None => rest.to_string(),
115 };
116 if path.is_empty() {
117 continue;
118 }
119 let (staged, unstaged) = if x == '?' && y == '?' {
120 (None, Some(Change::Untracked))
121 } else {
122 (Change::from_code(x), Change::from_code(y))
123 };
124 out.push(FileStatus {
125 path,
126 staged,
127 unstaged,
128 });
129 }
130 out
131}
132
133#[derive(Clone, Copy, PartialEq, Eq, Debug)]
135pub enum LineMark {
136 Added,
138 Modified,
140 Deleted,
142}
143
144#[must_use]
151pub fn diff_marks(head: &str, current: &str) -> Vec<(usize, LineMark)> {
152 use similar::{Algorithm, DiffOp, TextDiff};
153
154 let diff = TextDiff::configure()
155 .algorithm(Algorithm::Myers)
156 .diff_lines(head, current);
157 let current_lines = current.lines().count();
158 let mut marks = Vec::new();
159 for op in diff.ops() {
160 match *op {
161 DiffOp::Insert {
162 new_index, new_len, ..
163 } => {
164 for i in new_index..new_index + new_len {
165 marks.push((i, LineMark::Added));
166 }
167 }
168 DiffOp::Replace {
169 new_index, new_len, ..
170 } => {
171 for i in new_index..new_index + new_len {
172 marks.push((i, LineMark::Modified));
173 }
174 }
175 DiffOp::Delete { new_index, .. } => {
176 let at = new_index.min(current_lines.saturating_sub(1));
177 marks.push((at, LineMark::Deleted));
178 }
179 DiffOp::Equal { .. } => {}
180 }
181 }
182 marks
183}
184
185#[derive(Clone, PartialEq, Eq, Debug)]
188pub struct Hunk {
189 pub current_start: usize,
191 pub current_end: usize,
194 pub head_start: usize,
197 pub head_text: String,
200}
201
202impl Hunk {
203 #[must_use]
206 pub fn contains(&self, line: usize) -> bool {
207 if self.current_start == self.current_end {
208 line == self.current_start
209 } else {
210 line >= self.current_start && line < self.current_end
211 }
212 }
213}
214
215#[must_use]
219pub fn hunks(head: &str, current: &str) -> Vec<Hunk> {
220 use similar::{Algorithm, DiffOp, TextDiff};
221
222 let diff = TextDiff::configure()
223 .algorithm(Algorithm::Myers)
224 .diff_lines(head, current);
225 let head_lines: Vec<&str> = head.split_inclusive('\n').collect();
226 let mut out: Vec<Hunk> = Vec::new();
227 let mut run: Option<(usize, usize, usize, usize)> = None;
229
230 let flush = |run: &mut Option<(usize, usize, usize, usize)>, out: &mut Vec<Hunk>| {
231 if let Some((nlo, nhi, olo, ohi)) = run.take() {
232 let head_text = head_lines
233 .get(olo..ohi)
234 .map(<[&str]>::concat)
235 .unwrap_or_default();
236 out.push(Hunk {
237 current_start: nlo,
238 current_end: nhi,
239 head_start: olo,
240 head_text,
241 });
242 }
243 };
244
245 for op in diff.ops() {
246 let (ni, nl, oi, ol) = match *op {
247 DiffOp::Equal { .. } => {
248 flush(&mut run, &mut out);
249 continue;
250 }
251 DiffOp::Insert {
252 new_index,
253 new_len,
254 old_index,
255 ..
256 } => (new_index, new_len, old_index, 0),
257 DiffOp::Delete {
258 new_index,
259 old_index,
260 old_len,
261 ..
262 } => (new_index, 0, old_index, old_len),
263 DiffOp::Replace {
264 new_index,
265 new_len,
266 old_index,
267 old_len,
268 ..
269 } => (new_index, new_len, old_index, old_len),
270 };
271 run = Some(match run {
272 Some((nlo, nhi, olo, ohi)) => {
273 (nlo.min(ni), nhi.max(ni + nl), olo.min(oi), ohi.max(oi + ol))
274 }
275 None => (ni, ni + nl, oi, oi + ol),
276 });
277 }
278 flush(&mut run, &mut out);
279 out
280}
281
282#[derive(Clone, PartialEq, Eq, Debug, Default)]
285pub struct BlameLine {
286 pub hash: String,
288 pub author: String,
290 pub date: String,
292 pub summary: String,
294}
295
296impl BlameLine {
297 #[must_use]
299 pub fn is_uncommitted(&self) -> bool {
300 self.hash.chars().all(|c| c == '0')
301 }
302}
303
304#[must_use]
307pub fn parse_blame_porcelain(output: &str) -> Option<BlameLine> {
308 let mut hash = None;
309 let mut author = String::new();
310 let mut summary = String::new();
311 let mut time: i64 = 0;
312 let mut tz: i32 = 0;
313 for line in output.lines() {
314 if hash.is_none() {
315 if let Some(h) = line.split(' ').next().filter(|h| h.len() == 40) {
317 hash = Some(h.chars().take(8).collect::<String>());
318 }
319 continue;
320 }
321 if let Some(name) = line.strip_prefix("author ") {
322 author = name.to_string();
323 } else if let Some(secs) = line.strip_prefix("author-time ") {
324 time = secs.trim().parse().unwrap_or(0);
325 } else if let Some(off) = line.strip_prefix("author-tz ") {
326 tz = parse_tz_offset(off.trim());
327 } else if let Some(s) = line.strip_prefix("summary ") {
328 summary = s.to_string();
329 }
330 }
331 let hash = hash?;
332 Some(BlameLine {
333 hash,
334 author,
335 date: epoch_to_date(time, tz),
336 summary,
337 })
338}
339
340fn parse_tz_offset(tz: &str) -> i32 {
342 let sign = if tz.starts_with('-') { -1 } else { 1 };
343 let digits: String = tz.chars().filter(char::is_ascii_digit).collect();
344 if digits.len() < 4 {
345 return 0;
346 }
347 let hours: i32 = digits[0..2].parse().unwrap_or(0);
348 let mins: i32 = digits[2..4].parse().unwrap_or(0);
349 sign * (hours * 3600 + mins * 60)
350}
351
352#[must_use]
355fn epoch_to_date(secs: i64, tz_offset: i32) -> String {
356 let days = (secs + i64::from(tz_offset)).div_euclid(86_400);
357 let z = days + 719_468;
358 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
359 let doe = z - era * 146_097;
360 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
361 let y = yoe + era * 400;
362 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
363 let mp = (5 * doy + 2) / 153;
364 let d = doy - (153 * mp + 2) / 5 + 1;
365 let m = if mp < 10 { mp + 3 } else { mp - 9 };
366 let y = y + i64::from(m <= 2);
367 format!("{y:04}-{m:02}-{d:02}")
368}
369
370#[must_use]
373pub fn blame_line(dir: &Path, rel_path: &str, line: usize) -> Option<BlameLine> {
374 let spec = format!("{line},{line}");
375 let out = git_stdout(
376 dir,
377 &["blame", "--line-porcelain", "-L", &spec, "--", rel_path],
378 )?;
379 parse_blame_porcelain(&out)
380}
381
382fn git_program() -> &'static OsString {
394 static PROG: OnceLock<OsString> = OnceLock::new();
395 PROG.get_or_init(|| which_on_path("git").unwrap_or_else(|| "git".into()))
396}
397
398fn which_on_path(name: &str) -> Option<OsString> {
401 let exts: Vec<String> = if cfg!(windows) {
402 std::env::var("PATHEXT")
403 .unwrap_or_else(|_| ".EXE".into())
404 .split(';')
405 .map(str::to_string)
406 .collect()
407 } else {
408 vec![String::new()]
409 };
410 let path = std::env::var_os("PATH")?;
411 for dir in std::env::split_paths(&path) {
412 if dir.as_os_str().is_empty() {
413 continue; }
415 for ext in &exts {
416 let candidate = dir.join(format!("{name}{ext}"));
417 if candidate.is_file() {
418 return Some(candidate.into_os_string());
419 }
420 }
421 }
422 None
423}
424
425fn valid_ref_name(name: &str) -> bool {
431 !name.is_empty()
432 && !name.starts_with('-')
433 && !name.chars().any(|c| c.is_whitespace() || c.is_control())
434}
435
436fn git(dir: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
437 Command::new(git_program())
438 .current_dir(dir)
439 .args(args)
440 .output()
441}
442
443fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
445 let out = git(dir, args).ok()?;
446 if out.status.success() {
447 Some(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
448 } else {
449 None
450 }
451}
452
453#[must_use]
455pub fn is_repo(dir: &Path) -> bool {
456 git_stdout(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
457}
458
459#[must_use]
462pub fn branch(dir: &Path) -> Option<String> {
463 let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
464 if name == "HEAD" {
465 git_stdout(dir, &["rev-parse", "--short", "HEAD"])
467 } else {
468 Some(name)
469 }
470}
471
472#[must_use]
474pub fn is_dirty(dir: &Path) -> bool {
475 git_stdout(dir, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
476}
477
478#[must_use]
480pub fn status(dir: &Path) -> Vec<FileStatus> {
481 git_stdout(dir, &["status", "--porcelain"])
482 .map(|s| parse_status(&s))
483 .unwrap_or_default()
484}
485
486#[must_use]
489pub fn head_blob(dir: &Path, rel_path: &str) -> Option<String> {
490 let out = git(dir, &["show", &format!("HEAD:{rel_path}")]).ok()?;
493 out.status
494 .success()
495 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
496}
497
498#[must_use]
500pub fn stage(dir: &Path, rel_path: &str) -> bool {
501 git(dir, &["add", "--", rel_path]).is_ok_and(|o| o.status.success())
502}
503
504#[must_use]
507pub fn index_blob(dir: &Path, rel_path: &str) -> Option<String> {
508 let out = git(dir, &["show", &format!(":{rel_path}")]).ok()?;
509 out.status
510 .success()
511 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
512}
513
514pub fn stage_content(dir: &Path, rel_path: &str, content: &str) -> Result<(), String> {
521 let hash = git_stdin(
522 dir,
523 &["hash-object", "-w", "--path", rel_path, "--stdin"],
524 content,
525 )?;
526 let sha = hash.trim();
527 let mode = git_stdout(dir, &["ls-files", "-s", "--", rel_path])
530 .and_then(|s| s.split_whitespace().next().map(str::to_string))
531 .unwrap_or_else(|| "100644".to_string());
532 let info = format!("{mode},{sha},{rel_path}");
533 let out =
534 git(dir, &["update-index", "--add", "--cacheinfo", &info]).map_err(|e| e.to_string())?;
535 if out.status.success() {
536 Ok(())
537 } else {
538 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
539 }
540}
541
542fn git_stdin(dir: &Path, args: &[&str], input: &str) -> Result<String, String> {
544 use std::io::Write;
545 use std::process::Stdio;
546 let mut child = Command::new(git_program())
547 .current_dir(dir)
548 .args(args)
549 .stdin(Stdio::piped())
550 .stdout(Stdio::piped())
551 .stderr(Stdio::piped())
552 .spawn()
553 .map_err(|e| e.to_string())?;
554 child
555 .stdin
556 .take()
557 .ok_or_else(|| "no stdin".to_string())?
558 .write_all(input.as_bytes())
559 .map_err(|e| e.to_string())?;
560 let out = child.wait_with_output().map_err(|e| e.to_string())?;
561 if out.status.success() {
562 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
563 } else {
564 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
565 }
566}
567
568#[must_use]
570pub fn unstage(dir: &Path, rel_path: &str) -> bool {
571 git(dir, &["restore", "--staged", "--", rel_path]).is_ok_and(|o| o.status.success())
572}
573
574#[must_use]
577pub fn commit_count(dir: &Path) -> Option<u64> {
578 git_stdout(dir, &["rev-list", "--count", "HEAD"])?
579 .trim()
580 .parse()
581 .ok()
582}
583
584#[must_use]
586pub fn local_branches(dir: &Path) -> Vec<String> {
587 let out = git_stdout(dir, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
588 let mut names: Vec<String> = out
589 .lines()
590 .map(str::trim)
591 .filter(|s| !s.is_empty())
592 .map(String::from)
593 .collect();
594 if let Some(cur) = branch(dir)
595 && let Some(pos) = names.iter().position(|n| *n == cur)
596 {
597 names.swap(0, pos);
598 }
599 names
600}
601
602pub fn checkout(dir: &Path, branch: &str) -> Result<(), String> {
608 if !valid_ref_name(branch) {
609 return Err(format!("invalid branch name: {branch:?}"));
610 }
611 match git(dir, &["switch", "--end-of-options", branch]) {
614 Ok(o) if o.status.success() => Ok(()),
615 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
616 Err(e) => Err(e.to_string()),
617 }
618}
619
620pub fn create_branch(dir: &Path, name: &str) -> Result<(), String> {
627 if !valid_ref_name(name) {
628 return Err(format!("invalid branch name: {name:?}"));
629 }
630 match git(dir, &["switch", "-c", name]) {
631 Ok(o) if o.status.success() => Ok(()),
632 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
633 Err(e) => Err(e.to_string()),
634 }
635}
636
637pub fn commit(dir: &Path, message: &str) -> Result<(), String> {
643 run_ok(dir, &["commit", "-m", message])
644}
645
646pub fn commit_amend(dir: &Path) -> Result<(), String> {
652 run_ok(dir, &["commit", "--amend", "--no-edit"])
653}
654
655pub fn stash_push(dir: &Path) -> Result<(), String> {
660 run_ok(dir, &["stash", "push"])
661}
662
663pub fn stash_pop(dir: &Path) -> Result<(), String> {
668 run_ok(dir, &["stash", "pop"])
669}
670
671fn run_ok(dir: &Path, args: &[&str]) -> Result<(), String> {
673 match git(dir, args) {
674 Ok(o) if o.status.success() => Ok(()),
675 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
676 Err(e) => Err(e.to_string()),
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn valid_ref_name_rejects_option_shaped_and_control_input() {
686 assert!(valid_ref_name("main"));
687 assert!(valid_ref_name("feature/x-y"));
688 assert!(!valid_ref_name("--detach"));
690 assert!(!valid_ref_name("-oProxyCommand=x"));
691 assert!(!valid_ref_name("--upload-pack=evil"));
692 assert!(!valid_ref_name(""));
694 assert!(!valid_ref_name("a b"));
695 assert!(!valid_ref_name("a\nb"));
696 }
697
698 #[test]
699 fn checkout_and_create_branch_refuse_dash_prefixed_names() {
700 let dir = std::env::temp_dir();
702 assert!(checkout(&dir, "--detach").is_err());
703 assert!(create_branch(&dir, "-c").is_err());
704 assert!(create_branch(&dir, "--orphan").is_err());
705 }
706
707 #[test]
708 fn which_on_path_never_returns_a_relative_or_cwd_path() {
709 if let Some(p) = which_on_path("git") {
712 assert!(
713 Path::new(&p).is_absolute(),
714 "git resolved to non-absolute: {p:?}"
715 );
716 }
717 }
718
719 #[test]
720 fn hunks_groups_a_modified_line() {
721 let h = hunks("a\nb\nc\n", "a\nB\nc\n");
722 assert_eq!(h.len(), 1);
723 assert_eq!(h[0].current_start, 1);
724 assert_eq!(h[0].current_end, 2);
725 assert_eq!(h[0].head_text, "b\n");
726 assert!(h[0].contains(1));
727 assert!(!h[0].contains(0));
728 }
729
730 #[test]
731 fn hunks_addition_has_empty_head_text() {
732 let h = hunks("a\nc\n", "a\nb1\nb2\nc\n");
733 assert_eq!(h.len(), 1);
734 assert_eq!((h[0].current_start, h[0].current_end), (1, 3));
735 assert_eq!(h[0].head_text, ""); }
737
738 #[test]
739 fn hunks_deletion_anchors_a_zero_width_range() {
740 let h = hunks("a\nb\nc\n", "a\nc\n");
741 assert_eq!(h.len(), 1);
742 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));
745 }
746
747 #[test]
748 fn hunks_finds_multiple_regions() {
749 let h = hunks("a\nb\nc\nd\ne\n", "A\nb\nc\nD\ne\n");
750 assert_eq!(h.len(), 2);
751 assert_eq!(h[0].current_start, 0);
752 assert_eq!(h[1].current_start, 3);
753 }
754
755 #[test]
756 fn hunks_identical_text_is_empty() {
757 assert!(hunks("x\ny\n", "x\ny\n").is_empty());
758 }
759
760 #[test]
761 fn parse_status_reads_xy_codes_and_paths() {
762 let out = " M src/app.rs\nA src/new.rs\n?? notes.txt\nMM both.rs\nD gone.rs\n";
763 let rows = parse_status(out);
764 assert_eq!(rows.len(), 5);
765
766 assert_eq!(rows[0].path, "src/app.rs");
767 assert_eq!(rows[0].staged, None);
768 assert_eq!(rows[0].unstaged, Some(Change::Modified));
769
770 assert_eq!(rows[1].path, "src/new.rs");
771 assert_eq!(rows[1].staged, Some(Change::Added));
772 assert_eq!(rows[1].unstaged, None);
773
774 assert_eq!(rows[2].path, "notes.txt");
775 assert_eq!(rows[2].primary(), Some(Change::Untracked));
776
777 assert_eq!(rows[3].staged, Some(Change::Modified));
778 assert_eq!(rows[3].unstaged, Some(Change::Modified));
779
780 assert_eq!(rows[4].path, "gone.rs");
781 assert_eq!(rows[4].staged, Some(Change::Deleted));
782 }
783
784 #[test]
785 fn parse_status_records_rename_destination() {
786 let rows = parse_status("R old/name.rs -> new/name.rs\n");
787 assert_eq!(rows.len(), 1);
788 assert_eq!(rows[0].path, "new/name.rs");
789 assert_eq!(rows[0].staged, Some(Change::Renamed));
790 }
791
792 #[test]
793 fn parse_status_skips_blank_and_short_lines() {
794 assert!(parse_status("\n\nx\n").is_empty());
795 }
796
797 #[test]
798 fn diff_marks_flags_added_modified_and_deleted() {
799 let head = "alpha\nbeta\ngamma\n";
800 let current = "alpha\nBETA\ngamma\ndelta\n";
802 let marks = diff_marks(head, current);
803 assert!(
804 marks.contains(&(1, LineMark::Modified)),
805 "line 2 modified: {marks:?}"
806 );
807 assert!(
808 marks.contains(&(3, LineMark::Added)),
809 "line 4 added: {marks:?}"
810 );
811 }
812
813 #[test]
814 fn diff_marks_flags_deletion() {
815 let head = "a\nb\nc\n";
816 let current = "a\nc\n"; let marks = diff_marks(head, current);
818 assert!(
819 marks.iter().any(|&(_, m)| m == LineMark::Deleted),
820 "a deletion is marked: {marks:?}"
821 );
822 }
823
824 #[test]
825 fn diff_marks_empty_when_identical() {
826 assert!(diff_marks("x\ny\n", "x\ny\n").is_empty());
827 }
828
829 #[test]
830 fn parse_blame_porcelain_extracts_fields() {
831 let out = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 12 12 1\n\
832 author Ada Lovelace\n\
833 author-mail <ada@example.com>\n\
834 author-time 1700000000\n\
835 author-tz +0000\n\
836 committer Ada Lovelace\n\
837 summary Add the analytical engine\n\
838 filename engine.rs\n\
839 \tlet x = 1;\n";
840 let b = parse_blame_porcelain(out).expect("parsed");
841 assert_eq!(b.hash, "1a2b3c4d");
842 assert_eq!(b.author, "Ada Lovelace");
843 assert_eq!(b.date, "2023-11-14");
844 assert_eq!(b.summary, "Add the analytical engine");
845 assert!(!b.is_uncommitted());
846 }
847
848 #[test]
849 fn parse_blame_porcelain_flags_uncommitted() {
850 let out = "0000000000000000000000000000000000000000 1 1 1\n\
851 author Not Committed Yet\n\
852 author-time 1700000000\n\
853 author-tz +0000\n\
854 summary Version of engine.rs from engine.rs\n\
855 \tlet y = 2;\n";
856 let b = parse_blame_porcelain(out).expect("parsed");
857 assert!(b.is_uncommitted());
858 assert_eq!(b.author, "Not Committed Yet");
859 }
860
861 #[test]
862 fn parse_blame_porcelain_none_without_header() {
863 assert!(parse_blame_porcelain("not a blame\n").is_none());
864 }
865
866 #[test]
867 fn epoch_to_date_applies_tz_offset() {
868 assert_eq!(epoch_to_date(1_700_000_000, 0), "2023-11-14");
870 assert_eq!(epoch_to_date(1_700_000_000, 2 * 3600), "2023-11-15");
871 assert_eq!(epoch_to_date(0, 0), "1970-01-01");
872 }
873
874 #[test]
875 fn change_letters() {
876 assert_eq!(Change::Modified.letter(), 'M');
877 assert_eq!(Change::Untracked.letter(), '?');
878 }
879
880 #[test]
883 #[ignore = "needs git and an in-tree checkout"]
884 fn runners_report_branch_in_this_repo() {
885 let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
886 assert!(is_repo(dir));
887 assert!(branch(dir).is_some());
888 }
889}