1use anyhow::{bail, Context, Result};
5use chrono::{DateTime, Utc};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9pub(crate) fn git(repo: &Path, args: &[&str]) -> Result<String> {
15 let out = Command::new("git")
16 .arg("-C")
17 .arg(repo)
18 .args(args)
19 .output()
20 .context("git not found in PATH — install git")?;
21 if !out.status.success() {
22 bail!(
23 "git {:?} failed in {}: {}",
24 args,
25 repo.display(),
26 String::from_utf8_lossy(&out.stderr).trim()
27 );
28 }
29 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
30}
31
32pub fn default_branch(repo: &Path) -> Result<String> {
38 if let Ok(sym) = git(
39 repo,
40 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
41 ) {
42 if let Some(branch) = sym.strip_prefix("origin/") {
43 return Ok(branch.to_string());
44 }
45 }
46 for candidate in ["main", "master"] {
47 if git(
48 repo,
49 &["rev-parse", "--verify", &format!("refs/heads/{candidate}")],
50 )
51 .is_ok()
52 {
53 return Ok(candidate.to_string());
54 }
55 }
56 bail!(
57 "couldn't find the default branch in {} (expected origin/HEAD, main or master)",
58 repo.display()
59 )
60}
61
62#[derive(Debug, Clone)]
64pub struct OpenLoop {
65 pub root_label: String,
66 pub repo_name: String,
67 pub repo_path: PathBuf,
68 pub branch: String,
69 pub head_sha: String,
70 pub last_commit: DateTime<Utc>,
71 pub ahead: u32,
72 pub behind: u32,
73}
74
75impl OpenLoop {
76 pub fn key(&self) -> String {
78 format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
79 }
80}
81
82const SKIP_DIRS: [&str; 2] = ["node_modules", "target"];
83
84fn looks_like_bare(dir: &Path) -> bool {
85 dir.join("HEAD").is_file() && dir.join("objects").is_dir() && dir.join("refs").is_dir()
86}
87
88fn is_repo_candidate(dir: &Path) -> bool {
89 dir.join(".git").exists() || looks_like_bare(dir)
90}
91
92pub fn repo_name_from_common_dir(common_dir: &Path) -> String {
94 let base = common_dir
95 .file_name()
96 .map(|n| n.to_string_lossy().into_owned())
97 .unwrap_or_default();
98 if base == ".git" || base == ".bare" {
99 return common_dir
100 .parent()
101 .and_then(|p| p.file_name())
102 .map(|n| n.to_string_lossy().into_owned())
103 .unwrap_or(base);
104 }
105 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
106}
107
108pub fn git_common_dir(path: &Path) -> Result<PathBuf> {
114 let raw = git(
115 path,
116 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
117 )?;
118 Ok(PathBuf::from(raw))
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct WorktreeEntry {
124 pub path: PathBuf,
125 pub branch: Option<String>,
127 pub bare: bool,
128 pub prunable: bool,
129}
130
131pub fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
137 let mut entries = Vec::new();
138 let mut current: Option<WorktreeEntry> = None;
139 for line in out.lines() {
140 if let Some(p) = line.strip_prefix("worktree ") {
141 if let Some(e) = current.take() {
142 entries.push(e);
143 }
144 current = Some(WorktreeEntry {
145 path: PathBuf::from(p),
146 branch: None,
147 bare: false,
148 prunable: false,
149 });
150 } else if let Some(e) = current.as_mut() {
151 if let Some(b) = line.strip_prefix("branch ") {
152 e.branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
153 } else if line == "bare" {
154 e.bare = true;
155 } else if line == "prunable" || line.starts_with("prunable ") {
156 e.prunable = true;
157 }
158 }
159 }
160 if let Some(e) = current.take() {
161 entries.push(e);
162 }
163 entries
164}
165
166pub fn worktree_map(repo: &Path) -> Result<std::collections::HashMap<String, PathBuf>> {
175 let raw = git(repo, &["worktree", "list", "--porcelain"])?;
176 Ok(parse_worktree_porcelain(&raw)
177 .into_iter()
178 .filter(|e| !e.bare)
179 .filter_map(|e| e.branch.map(|b| (b, e.path)))
180 .collect())
181}
182
183pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<PathBuf>, Vec<String>) {
186 let mut candidates = Vec::new();
187 for root in roots {
188 walk(root, 0, scan_depth, &mut candidates);
189 }
190 dedup_candidates(candidates)
191}
192
193fn dedup_candidates(candidates: Vec<PathBuf>) -> (Vec<PathBuf>, Vec<String>) {
194 use std::collections::HashMap;
195 let mut by_common: HashMap<PathBuf, PathBuf> = HashMap::new();
196 let mut warnings = Vec::new();
197 for candidate in candidates {
198 match git_common_dir(&candidate) {
199 Ok(common) => {
200 by_common.entry(common).or_insert(candidate);
201 }
202 Err(e) => {
203 warnings.push(format!("{}: {e:#}", candidate.display()));
204 }
205 }
206 }
207 let mut repos: Vec<PathBuf> = by_common.into_values().collect();
208 repos.sort();
209 (repos, warnings)
210}
211
212fn walk(dir: &Path, depth: usize, scan_depth: usize, candidates: &mut Vec<PathBuf>) {
213 if is_repo_candidate(dir) {
214 candidates.push(dir.to_path_buf());
215 return;
216 }
217 if depth >= scan_depth {
218 return;
219 }
220 let Ok(entries) = std::fs::read_dir(dir) else {
221 return;
222 };
223 for entry in entries.flatten() {
224 let path = entry.path();
225 let name = entry.file_name();
226 let name = name.to_string_lossy();
227 if !path.is_dir() || name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
228 continue;
229 }
230 walk(&path, depth + 1, scan_depth, candidates);
231 }
232}
233
234pub fn open_loops(repo: &Path, root_label: &str) -> Result<Vec<OpenLoop>> {
240 let default = default_branch(repo)?;
241 let common_dir = git_common_dir(repo)?;
242 let repo_name = repo_name_from_common_dir(&common_dir);
243 let worktrees = worktree_map(repo).unwrap_or_else(|e| {
244 eprintln!(
245 "warning: git worktree list failed in {}: {e:#}; session matching falls back to the repo path",
246 repo.display()
247 );
248 std::collections::HashMap::new()
249 });
250 let merged: std::collections::HashSet<String> = git(
251 repo,
252 &["branch", "--merged", &default, "--format=%(refname:short)"],
253 )?
254 .lines()
255 .map(|s| s.trim().to_string())
256 .collect();
257 let raw = git(
258 repo,
259 &[
260 "for-each-ref",
261 "refs/heads",
262 "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)",
263 ],
264 )?;
265 let mut result = Vec::new();
266 for line in raw.lines() {
267 let mut parts = line.split('\t');
268 let (Some(branch), Some(sha), Some(date)) = (parts.next(), parts.next(), parts.next())
269 else {
270 eprintln!("warning: unexpected line from git for-each-ref ignored: {line:?}");
271 continue;
272 };
273 if branch == default || merged.contains(branch) {
274 continue;
275 }
276 let counts = git(
277 repo,
278 &[
279 "rev-list",
280 "--left-right",
281 "--count",
282 &format!("{default}...{branch}"),
283 ],
284 )?;
285 let mut c = counts.split_whitespace();
286 let behind: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
287 let ahead: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
288 let last_commit = DateTime::parse_from_rfc3339(date)
289 .with_context(|| format!("invalid date from git: {date}"))?
290 .with_timezone(&Utc);
291 let repo_path = worktrees
292 .get(branch)
293 .cloned()
294 .unwrap_or_else(|| repo.to_path_buf());
295 result.push(OpenLoop {
296 root_label: root_label.to_string(),
297 repo_name: repo_name.clone(),
298 repo_path,
299 branch: branch.to_string(),
300 head_sha: sha.to_string(),
301 last_commit,
302 ahead,
303 behind,
304 });
305 }
306 Ok(result)
307}
308
309pub fn scan(
313 roots: &[PathBuf],
314 labels: &[(PathBuf, String)],
315 scan_depth: usize,
316) -> (Vec<OpenLoop>, Vec<String>) {
317 let (repos, mut warnings) = find_repos(roots, scan_depth);
318 let results: Vec<Result<Vec<OpenLoop>>> = std::thread::scope(|s| {
319 let handles: Vec<_> = repos
320 .iter()
321 .map(|repo| {
322 let label = crate::config::label_for_repo(labels, repo);
323 s.spawn(move || open_loops(repo, &label))
324 })
325 .collect();
326 handles
327 .into_iter()
328 .map(|h| {
329 h.join()
330 .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning repository")))
331 })
332 .collect()
333 });
334 let mut all = Vec::new();
335 for (repo, res) in repos.iter().zip(results) {
336 match res {
337 Ok(mut loops) => all.append(&mut loops),
338 Err(e) => warnings.push(format!("{}: {e:#}", repo.display())),
339 }
340 }
341 (all, warnings)
342}
343
344pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
350 git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
351}
352
353pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
359 git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
360}
361
362pub fn commit_window(
370 repo: &Path,
371 default: &str,
372 branch: &str,
373) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
374 let raw = git(
375 repo,
376 &["log", "--format=%cI", &format!("{default}..{branch}")],
377 )?;
378 let mut dates: Vec<DateTime<Utc>> = raw
379 .lines()
380 .filter_map(|l| DateTime::parse_from_rfc3339(l.trim()).ok())
381 .map(|d| d.with_timezone(&Utc))
382 .collect();
383 if dates.is_empty() {
384 let head = git(repo, &["log", "-1", "--format=%cI", branch])?;
386 dates.push(DateTime::parse_from_rfc3339(head.trim())?.with_timezone(&Utc));
387 }
388 let min = dates
389 .iter()
390 .min()
391 .copied()
392 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
393 let max = dates
394 .iter()
395 .max()
396 .copied()
397 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
398 Ok((min, max))
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::testutil;
405
406 #[test]
407 fn default_branch_detects_main() {
408 let tmp = tempfile::tempdir().unwrap();
409 let repo = tmp.path().join("app");
410 testutil::init_repo(&repo);
411 assert_eq!(default_branch(&repo).unwrap(), "main");
412 }
413
414 #[test]
415 fn git_fails_with_contextual_message() {
416 let tmp = tempfile::tempdir().unwrap();
417 let err = git(tmp.path(), &["status"]).unwrap_err();
419 assert!(err.to_string().contains(&tmp.path().display().to_string()));
420 }
421
422 #[test]
423 fn find_repos_dedups_container_and_worktrees() {
424 let tmp = tempfile::tempdir().unwrap();
425 let container = tmp.path().join("my-app");
426 testutil::init_bare_worktree_container(&container);
427 let dev = container.join("dev");
428 testutil::add_named_worktree(&container, "dev", "dev");
429 let (repos, warnings) = find_repos(&[container.clone(), dev], 4);
430 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
431 assert_eq!(repos.len(), 1);
432 assert_eq!(repos[0], container);
433 }
434
435 #[test]
436 fn find_repos_respects_scan_depth_and_skips_hidden() {
437 let tmp = tempfile::tempdir().unwrap();
438 testutil::init_repo(&tmp.path().join("a/b/c/repo-deep"));
439 testutil::init_repo(&tmp.path().join("a/b/repo-mid"));
440 testutil::init_repo(&tmp.path().join("repo-shallow"));
441 testutil::init_repo(&tmp.path().join(".hidden/repo3"));
442
443 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
444 let names: Vec<_> = repos
445 .iter()
446 .filter_map(|r| r.file_name())
447 .map(|n| n.to_string_lossy().into_owned())
448 .collect();
449 assert!(names.contains(&"repo-deep".to_string()));
450 assert!(names.contains(&"repo-mid".to_string()));
451 assert!(names.contains(&"repo-shallow".to_string()));
452 assert!(!names.contains(&"repo3".to_string()));
453
454 let (shallow, _) = find_repos(&[tmp.path().to_path_buf()], 2);
455 let shallow_names: Vec<_> = shallow
456 .iter()
457 .filter_map(|r| r.file_name())
458 .map(|n| n.to_string_lossy().into_owned())
459 .collect();
460 assert!(!shallow_names.contains(&"repo-deep".to_string()));
461 assert!(shallow_names.contains(&"repo-shallow".to_string()));
462 }
463
464 #[test]
465 fn find_repos_finds_normal_git_dir_repo() {
466 let tmp = tempfile::tempdir().unwrap();
467 testutil::init_repo(&tmp.path().join("app"));
468 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
469 assert_eq!(repos.len(), 1);
470 }
471
472 #[test]
473 fn find_repos_finds_bare_worktree_container_via_git_file() {
474 let tmp = tempfile::tempdir().unwrap();
475 let container = tmp.path().join("my-app");
476 testutil::init_bare_worktree_container(&container);
477 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
478 assert_eq!(repos.len(), 1);
479 assert_eq!(repos[0], container);
480 }
481
482 #[test]
483 fn find_repos_finds_pure_bare_repo() {
484 let tmp = tempfile::tempdir().unwrap();
485 let bare = tmp.path().join("foo.git");
486 testutil::init_bare_repo(&bare);
487 testutil::seed_bare_main(&bare);
488 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
489 assert_eq!(repos.len(), 1);
490 assert_eq!(repos[0], bare);
491 }
492
493 #[test]
494 fn open_loops_uses_common_dir_repo_name_in_bare_layout() {
495 let tmp = tempfile::tempdir().unwrap();
496 let container = tmp.path().join("my-app");
497 testutil::init_bare_worktree_container(&container);
498 testutil::add_named_worktree(&container, "dev", "dev");
499 testutil::add_branch_on_bare(&container.join(".bare"), "feat/x", "x.txt");
500
501 let loops = open_loops(&container, "root").unwrap();
502 assert_eq!(loops.len(), 1);
503 assert_eq!(loops[0].repo_name, "my-app");
504 assert_eq!(loops[0].branch, "feat/x");
505 assert_eq!(loops[0].key(), "root/my-app/feat/x");
506 }
507
508 #[test]
509 fn open_loops_bare_root_repo_name_strips_dot_git_suffix() {
510 let tmp = tempfile::tempdir().unwrap();
511 let bare = tmp.path().join("foo.git");
512 testutil::init_bare_repo(&bare);
513 testutil::seed_bare_main(&bare);
514 testutil::add_branch_on_bare(&bare, "feat/y", "y.txt");
515
516 let loops = open_loops(&bare, "r").unwrap();
517 assert_eq!(loops[0].repo_name, "foo");
518 }
519
520 #[test]
521 fn open_loops_finds_unmerged_ignores_merged_and_default() {
522 let tmp = tempfile::tempdir().unwrap();
523 let repo = tmp.path().join("app");
524 testutil::init_repo(&repo);
525 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
526 testutil::git(&repo, &["branch", "merged"]); let loops = open_loops(&repo, "root").unwrap();
529 assert_eq!(loops.len(), 1);
530 let l = &loops[0];
531 assert_eq!(l.branch, "feat/x");
532 assert_eq!(l.repo_name, "app");
533 assert_eq!(l.root_label, "root");
534 assert_eq!(l.key(), "root/app/feat/x");
535 assert_eq!(l.ahead, 1);
536 assert_eq!(l.behind, 0);
537 assert_eq!(l.head_sha.len(), 40);
538 }
539
540 #[test]
541 fn open_loops_sets_repo_path_to_worktree_when_branch_checked_out() {
542 let tmp = tempfile::tempdir().unwrap();
543 let container = tmp.path().join("my-app");
544 testutil::init_bare_worktree_container(&container);
545 testutil::add_worktree_with_commit(&container, "feat-x", "feat/x", "x.txt");
546
547 let loops = open_loops(&container, "root").unwrap();
548 let lp = loops
549 .iter()
550 .find(|l| l.branch == "feat/x")
551 .expect("feat/x loop");
552 assert_eq!(lp.repo_path, container.join("feat-x"));
553 }
554
555 #[test]
556 fn open_loops_falls_back_to_container_when_branch_has_no_worktree() {
557 let tmp = tempfile::tempdir().unwrap();
558 let container = tmp.path().join("my-app");
559 testutil::init_bare_worktree_container(&container);
560 testutil::add_branch_on_bare(&container.join(".bare"), "feat/y", "y.txt");
562
563 let loops = open_loops(&container, "root").unwrap();
564 let lp = loops
565 .iter()
566 .find(|l| l.branch == "feat/y")
567 .expect("feat/y loop");
568 assert_eq!(lp.repo_path, container);
569 }
570
571 #[test]
572 fn open_loops_normal_repo_keeps_repo_path_as_repo_dir() {
573 let tmp = tempfile::tempdir().unwrap();
574 let repo = tmp.path().join("app");
575 testutil::init_repo(&repo);
576 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); let loops = open_loops(&repo, "root").unwrap();
578 assert_eq!(loops[0].branch, "feat/x");
579 assert_eq!(loops[0].repo_path, repo); }
581
582 #[test]
583 fn scan_aggregates_repos_and_reports_warning_without_aborting() {
584 let tmp = tempfile::tempdir().unwrap();
585 let good = tmp.path().join("good");
586 testutil::init_repo(&good);
587 testutil::add_branch_with_commit(&good, "feat/ok", "ok.txt");
588 let empty = tmp.path().join("empty");
590 std::fs::create_dir_all(&empty).unwrap();
591 testutil::git(&empty, &["init", "-b", "main"]);
592
593 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
594 let (loops, warnings) = scan(&[tmp.path().to_path_buf()], &labels, 4);
595 assert_eq!(loops.len(), 1);
596 assert_eq!(loops[0].key(), "r/good/feat/ok");
597 assert_eq!(warnings.len(), 1);
598 assert!(warnings[0].contains("empty"));
599 }
600
601 #[test]
602 fn context_helpers_return_commits_and_window() {
603 let tmp = tempfile::tempdir().unwrap();
604 let repo = tmp.path().join("app");
605 testutil::init_repo(&repo);
606 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
607
608 let log = git_log(&repo, "main", "feat/x").unwrap();
609 assert!(log.contains("wip feat/x"));
610 let stat = diffstat(&repo, "main", "feat/x").unwrap();
611 assert!(stat.contains("x.txt"));
612 let (start, end) = commit_window(&repo, "main", "feat/x").unwrap();
613 assert!(start <= end);
614 }
615
616 #[test]
617 fn default_branch_detects_master_fallback() {
618 let tmp = tempfile::tempdir().unwrap();
619 let repo = tmp.path();
620 testutil::git(repo, &["init", "-b", "master"]);
621 std::fs::write(repo.join("a.txt"), "a").unwrap();
622 testutil::git(repo, &["add", "."]);
623 testutil::git(repo, &["commit", "-m", "init"]);
624 assert_eq!(default_branch(repo).unwrap(), "master");
625 }
626
627 #[test]
628 fn default_branch_errors_without_main_or_master() {
629 let tmp = tempfile::tempdir().unwrap();
630 let repo = tmp.path();
631 testutil::git(repo, &["init", "-b", "trunk"]);
632 let err = default_branch(repo).unwrap_err();
634 assert!(err.to_string().contains("couldn't find the default branch"));
635 }
636
637 #[test]
638 fn git_common_dir_resolves_normal_and_bare_pointer() {
639 let tmp = tempfile::tempdir().unwrap();
640 let normal = tmp.path().join("app");
641 testutil::init_repo(&normal);
642 let normal_common = git_common_dir(&normal).unwrap();
643 assert!(normal_common.ends_with(".git"));
644
645 let container = tmp.path().join("container");
646 testutil::init_bare_worktree_container(&container);
647 let bare_common = git_common_dir(&container).unwrap();
648 assert!(bare_common.ends_with(".bare"));
649 }
650
651 #[test]
652 fn parse_worktree_porcelain_extracts_branches_and_flags() {
653 let out = "\
654worktree /home/u/app/main
655HEAD aaaaaaaa
656branch refs/heads/main
657
658worktree /home/u/app/feat-x
659HEAD bbbbbbbb
660branch refs/heads/feat/x
661
662worktree /home/u/app/detached
663HEAD cccccccc
664detached
665
666worktree /home/u/app/.bare
667bare
668";
669 let entries = parse_worktree_porcelain(out);
670 assert_eq!(entries.len(), 4);
671 assert_eq!(entries[0].branch.as_deref(), Some("main"));
672 assert_eq!(
673 entries[0].path,
674 std::path::PathBuf::from("/home/u/app/main")
675 );
676 assert_eq!(entries[1].branch.as_deref(), Some("feat/x")); assert_eq!(entries[2].branch, None); assert!(entries[3].bare);
679 assert_eq!(entries[3].branch, None);
680 }
681
682 #[test]
683 fn parse_worktree_porcelain_marks_prunable_and_handles_empty() {
684 assert!(parse_worktree_porcelain("").is_empty());
685 let out = "worktree /gone\nprunable gitdir file points to non-existent location\n";
686 let entries = parse_worktree_porcelain(out);
687 assert_eq!(entries.len(), 1);
688 assert!(entries[0].prunable);
689 assert_eq!(entries[0].branch, None);
690 }
691
692 #[test]
693 fn worktree_map_maps_checked_out_branches_to_paths() {
694 let tmp = tempfile::tempdir().unwrap();
695 let container = tmp.path().join("my-app");
696 testutil::init_bare_worktree_container(&container); testutil::add_named_worktree(&container, "dev", "dev"); let map = worktree_map(&container).unwrap();
700 assert_eq!(map.get("main"), Some(&container.join("main")));
701 assert_eq!(map.get("dev"), Some(&container.join("dev")));
702 assert!(!map.values().any(|p| p.ends_with(".bare")));
704 }
705
706 #[test]
707 fn worktree_map_errors_on_non_git_dir() {
708 let tmp = tempfile::tempdir().unwrap();
709 assert!(worktree_map(tmp.path()).is_err());
711 }
712
713 #[test]
714 fn parse_worktree_porcelain_ignores_lines_before_first_worktree() {
715 let out = "branch refs/heads/orphan\nHEAD deadbeef\nworktree /home/u/app/main\nbranch refs/heads/main\n";
716 let entries = parse_worktree_porcelain(out);
717 assert_eq!(entries.len(), 1);
718 assert_eq!(
719 entries[0].path,
720 std::path::PathBuf::from("/home/u/app/main")
721 );
722 assert_eq!(entries[0].branch.as_deref(), Some("main"));
723 }
724
725 #[test]
726 fn repo_name_from_common_dir_table() {
727 use std::path::Path;
728
729 let cases: &[(&str, &str)] = &[
730 ("/home/u/my-app/.bare", "my-app"),
731 ("/home/u/app/.git", "app"),
732 ("/srv/git/foo.git", "foo"),
733 ("/srv/git/myproject", "myproject"),
734 ];
735 for (common, want) in cases {
736 assert_eq!(
737 repo_name_from_common_dir(Path::new(common)),
738 *want,
739 "common_dir={common}"
740 );
741 }
742 }
743}