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 RepoCandidate {
65 pub path: PathBuf,
66 pub repo_name: String,
68}
69
70#[derive(Debug, Clone)]
72pub struct OpenLoop {
73 pub root_label: String,
74 pub repo_name: String,
75 pub repo_path: PathBuf,
76 pub branch: String,
77 pub head_sha: String,
78 pub last_commit: DateTime<Utc>,
79 pub ahead: Option<u32>,
80 pub behind: Option<u32>,
81}
82
83impl OpenLoop {
84 pub fn key(&self) -> String {
86 format!("{}/{}/{}", self.root_label, self.repo_name, self.branch)
87 }
88}
89
90const SKIP_DIRS: [&str; 2] = ["node_modules", "target"];
91
92fn looks_like_bare(dir: &Path) -> bool {
93 dir.join("HEAD").is_file() && dir.join("objects").is_dir() && dir.join("refs").is_dir()
94}
95
96fn is_repo_candidate(dir: &Path) -> bool {
97 dir.join(".git").exists() || looks_like_bare(dir)
98}
99
100pub fn repo_name_from_common_dir(common_dir: &Path) -> String {
102 let base = common_dir
103 .file_name()
104 .map(|n| n.to_string_lossy().into_owned())
105 .unwrap_or_default();
106 if base == ".git" || base == ".bare" {
107 return common_dir
108 .parent()
109 .and_then(|p| p.file_name())
110 .map(|n| n.to_string_lossy().into_owned())
111 .unwrap_or(base);
112 }
113 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
114}
115
116pub fn git_common_dir(path: &Path) -> Result<PathBuf> {
122 let raw = git(
123 path,
124 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
125 )?;
126 Ok(PathBuf::from(raw))
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct WorktreeEntry {
132 pub path: PathBuf,
133 pub branch: Option<String>,
135 pub bare: bool,
136 pub prunable: bool,
137}
138
139pub fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
145 let mut entries = Vec::new();
146 let mut current: Option<WorktreeEntry> = None;
147 for line in out.lines() {
148 if let Some(p) = line.strip_prefix("worktree ") {
149 if let Some(e) = current.take() {
150 entries.push(e);
151 }
152 current = Some(WorktreeEntry {
153 path: PathBuf::from(p),
154 branch: None,
155 bare: false,
156 prunable: false,
157 });
158 } else if let Some(e) = current.as_mut() {
159 if let Some(b) = line.strip_prefix("branch ") {
160 e.branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
161 } else if line == "bare" {
162 e.bare = true;
163 } else if line == "prunable" || line.starts_with("prunable ") {
164 e.prunable = true;
165 }
166 }
167 }
168 if let Some(e) = current.take() {
169 entries.push(e);
170 }
171 entries
172}
173
174fn normalize_path(path: PathBuf) -> PathBuf {
175 std::fs::canonicalize(&path).unwrap_or(path)
176}
177
178pub fn worktree_map(repo: &Path) -> Result<std::collections::HashMap<String, PathBuf>> {
187 let raw = git(repo, &["worktree", "list", "--porcelain"])?;
188 Ok(parse_worktree_porcelain(&raw)
189 .into_iter()
190 .filter(|e| !e.bare)
191 .filter_map(|e| e.branch.map(|b| (b, normalize_path(e.path))))
192 .collect())
193}
194
195pub fn find_repos(roots: &[PathBuf], scan_depth: usize) -> (Vec<RepoCandidate>, Vec<String>) {
198 let mut candidates = Vec::new();
199 for root in roots {
200 walk(root, 0, scan_depth, &mut candidates);
201 }
202 dedup_candidates(candidates)
203}
204
205fn dedup_candidates(candidates: Vec<PathBuf>) -> (Vec<RepoCandidate>, Vec<String>) {
206 use std::collections::HashMap;
207 let mut by_common: HashMap<PathBuf, RepoCandidate> = HashMap::new();
208 let mut warnings = Vec::new();
209 for candidate in candidates {
210 match git_common_dir(&candidate) {
211 Ok(common) => {
212 let repo_name = repo_name_from_common_dir(&common);
213 by_common.entry(common).or_insert(RepoCandidate {
214 path: candidate,
215 repo_name,
216 });
217 }
218 Err(e) => {
219 warnings.push(format!("{}: {e:#}", candidate.display()));
220 }
221 }
222 }
223 let mut repos: Vec<RepoCandidate> = by_common.into_values().collect();
224 repos.sort_by(|a, b| a.path.cmp(&b.path));
225 (repos, warnings)
226}
227
228fn walk(dir: &Path, depth: usize, scan_depth: usize, candidates: &mut Vec<PathBuf>) {
229 if is_repo_candidate(dir) {
230 candidates.push(dir.to_path_buf());
231 return;
232 }
233 if depth >= scan_depth {
234 return;
235 }
236 let Ok(entries) = std::fs::read_dir(dir) else {
237 return;
238 };
239 for entry in entries.flatten() {
240 let path = entry.path();
241 let name = entry.file_name();
242 let name = name.to_string_lossy();
243 if !path.is_dir() || name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
244 continue;
245 }
246 walk(&path, depth + 1, scan_depth, candidates);
247 }
248}
249
250pub fn repo_name_hint(path: &Path) -> String {
253 let base = path
254 .file_name()
255 .map(|n| n.to_string_lossy().into_owned())
256 .unwrap_or_default();
257 base.strip_suffix(".git").map(str::to_owned).unwrap_or(base)
258}
259
260pub fn open_loops(repo: &Path, root_label: &str, need_ahead_behind: bool) -> Result<Vec<OpenLoop>> {
269 let default = default_branch(repo)?;
270 let common_dir = git_common_dir(repo)?;
271 let repo_name = repo_name_from_common_dir(&common_dir);
272 let worktrees = worktree_map(repo).unwrap_or_else(|e| {
273 eprintln!(
274 "warning: git worktree list failed in {}: {e:#}; session matching falls back to the repo path",
275 repo.display()
276 );
277 std::collections::HashMap::new()
278 });
279 let merged: std::collections::HashSet<String> = git(
280 repo,
281 &["branch", "--merged", &default, "--format=%(refname:short)"],
282 )?
283 .lines()
284 .map(|s| s.trim().to_string())
285 .collect();
286 let raw = git(
287 repo,
288 &[
289 "for-each-ref",
290 "refs/heads",
291 "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso8601-strict)",
292 ],
293 )?;
294 let mut result = Vec::new();
295 for line in raw.lines() {
296 let mut parts = line.split('\t');
297 let (Some(branch), Some(sha), Some(date)) = (parts.next(), parts.next(), parts.next())
298 else {
299 eprintln!("warning: unexpected line from git for-each-ref ignored: {line:?}");
300 continue;
301 };
302 if branch == default || merged.contains(branch) {
303 continue;
304 }
305 let (ahead, behind) = if need_ahead_behind {
306 let counts = git(
307 repo,
308 &[
309 "rev-list",
310 "--left-right",
311 "--count",
312 &format!("{default}...{branch}"),
313 ],
314 )?;
315 let mut c = counts.split_whitespace();
316 let behind: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
317 let ahead: u32 = c.next().unwrap_or("0").parse().unwrap_or(0);
318 (Some(ahead), Some(behind))
319 } else {
320 (None, None)
321 };
322 let last_commit = DateTime::parse_from_rfc3339(date)
323 .with_context(|| format!("invalid date from git: {date}"))?
324 .with_timezone(&Utc);
325 let repo_path = worktrees
326 .get(branch)
327 .cloned()
328 .unwrap_or_else(|| repo.to_path_buf());
329 result.push(OpenLoop {
330 root_label: root_label.to_string(),
331 repo_name: repo_name.clone(),
332 repo_path,
333 branch: branch.to_string(),
334 head_sha: sha.to_string(),
335 last_commit,
336 ahead,
337 behind,
338 });
339 }
340 Ok(result)
341}
342
343pub fn scan(
349 roots: &[PathBuf],
350 labels: &[(PathBuf, String)],
351 scan_depth: usize,
352 need_ahead_behind: bool,
353 repo_filter: Option<&str>,
354) -> (Vec<OpenLoop>, Vec<String>) {
355 let (mut repos, mut warnings) = find_repos(roots, scan_depth);
356 if let Some(filter) = repo_filter {
357 let needle = filter.to_lowercase();
358 repos.retain(|r| r.repo_name.to_lowercase().contains(&needle));
359 }
360 let results: Vec<Result<Vec<OpenLoop>>> = std::thread::scope(|s| {
361 let handles: Vec<_> = repos
362 .iter()
363 .map(|repo| {
364 let label = crate::config::label_for_repo(labels, &repo.path);
365 let path = repo.path.clone();
366 s.spawn(move || open_loops(&path, &label, need_ahead_behind))
367 })
368 .collect();
369 handles
370 .into_iter()
371 .map(|h| {
372 h.join()
373 .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning repository")))
374 })
375 .collect()
376 });
377 let mut all = Vec::new();
378 for (repo, res) in repos.iter().zip(results) {
379 match res {
380 Ok(mut loops) => all.append(&mut loops),
381 Err(e) => warnings.push(format!("{}: {e:#}", repo.path.display())),
382 }
383 }
384 (all, warnings)
385}
386
387pub fn git_log(repo: &Path, default: &str, branch: &str) -> Result<String> {
393 git(repo, &["log", "--oneline", &format!("{default}..{branch}")])
394}
395
396pub fn diffstat(repo: &Path, default: &str, branch: &str) -> Result<String> {
402 git(repo, &["diff", "--stat", &format!("{default}...{branch}")])
403}
404
405pub fn commit_window(
413 repo: &Path,
414 default: &str,
415 branch: &str,
416) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
417 let raw = git(
418 repo,
419 &["log", "--format=%cI", &format!("{default}..{branch}")],
420 )?;
421 let mut dates: Vec<DateTime<Utc>> = raw
422 .lines()
423 .filter_map(|l| DateTime::parse_from_rfc3339(l.trim()).ok())
424 .map(|d| d.with_timezone(&Utc))
425 .collect();
426 if dates.is_empty() {
427 let head = git(repo, &["log", "-1", "--format=%cI", branch])?;
429 dates.push(DateTime::parse_from_rfc3339(head.trim())?.with_timezone(&Utc));
430 }
431 let min = dates
432 .iter()
433 .min()
434 .copied()
435 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
436 let max = dates
437 .iter()
438 .max()
439 .copied()
440 .ok_or_else(|| anyhow::anyhow!("no commit dates for {branch}"))?;
441 Ok((min, max))
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::testutil;
448
449 fn assert_same_path(actual: &std::path::Path, expected: &std::path::Path) {
450 let a = std::fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf());
451 let b = std::fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf());
452 assert_eq!(a, b);
453 }
454
455 #[test]
456 fn default_branch_detects_main() {
457 let tmp = tempfile::tempdir().unwrap();
458 let repo = tmp.path().join("app");
459 testutil::init_repo(&repo);
460 assert_eq!(default_branch(&repo).unwrap(), "main");
461 }
462
463 #[test]
464 fn git_fails_with_contextual_message() {
465 let tmp = tempfile::tempdir().unwrap();
466 let err = git(tmp.path(), &["status"]).unwrap_err();
468 assert!(err.to_string().contains(&tmp.path().display().to_string()));
469 }
470
471 #[test]
472 fn find_repos_dedups_container_and_worktrees() {
473 let tmp = tempfile::tempdir().unwrap();
474 let container = tmp.path().join("my-app");
475 testutil::init_bare_worktree_container(&container);
476 let dev = container.join("dev");
477 testutil::add_named_worktree(&container, "dev", "dev");
478 let (repos, warnings) = find_repos(&[container.clone(), dev], 4);
479 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
480 assert_eq!(repos.len(), 1);
481 assert_eq!(repos[0].path, container);
482 }
483
484 #[test]
485 fn find_repos_respects_scan_depth_and_skips_hidden() {
486 let tmp = tempfile::tempdir().unwrap();
487 testutil::init_repo(&tmp.path().join("a/b/c/repo-deep"));
488 testutil::init_repo(&tmp.path().join("a/b/repo-mid"));
489 testutil::init_repo(&tmp.path().join("repo-shallow"));
490 testutil::init_repo(&tmp.path().join(".hidden/repo3"));
491
492 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
493 let names: Vec<_> = repos
494 .iter()
495 .filter_map(|r| r.path.file_name())
496 .map(|n| n.to_string_lossy().into_owned())
497 .collect();
498 assert!(names.contains(&"repo-deep".to_string()));
499 assert!(names.contains(&"repo-mid".to_string()));
500 assert!(names.contains(&"repo-shallow".to_string()));
501 assert!(!names.contains(&"repo3".to_string()));
502
503 let (shallow, _) = find_repos(&[tmp.path().to_path_buf()], 2);
504 let shallow_names: Vec<_> = shallow
505 .iter()
506 .filter_map(|r| r.path.file_name())
507 .map(|n| n.to_string_lossy().into_owned())
508 .collect();
509 assert!(!shallow_names.contains(&"repo-deep".to_string()));
510 assert!(shallow_names.contains(&"repo-shallow".to_string()));
511 }
512
513 #[test]
514 fn find_repos_finds_normal_git_dir_repo() {
515 let tmp = tempfile::tempdir().unwrap();
516 testutil::init_repo(&tmp.path().join("app"));
517 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
518 assert_eq!(repos.len(), 1);
519 }
520
521 #[test]
522 fn find_repos_finds_bare_worktree_container_via_git_file() {
523 let tmp = tempfile::tempdir().unwrap();
524 let container = tmp.path().join("my-app");
525 testutil::init_bare_worktree_container(&container);
526 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
527 assert_eq!(repos.len(), 1);
528 assert_eq!(repos[0].path, container);
529 }
530
531 #[test]
532 fn find_repos_finds_pure_bare_repo() {
533 let tmp = tempfile::tempdir().unwrap();
534 let bare = tmp.path().join("foo.git");
535 testutil::init_bare_repo(&bare);
536 testutil::seed_bare_main(&bare);
537 let (repos, _) = find_repos(&[tmp.path().to_path_buf()], 4);
538 assert_eq!(repos.len(), 1);
539 assert_eq!(repos[0].path, bare);
540 }
541
542 #[test]
543 fn open_loops_uses_common_dir_repo_name_in_bare_layout() {
544 let tmp = tempfile::tempdir().unwrap();
545 let container = tmp.path().join("my-app");
546 testutil::init_bare_worktree_container(&container);
547 testutil::add_named_worktree(&container, "dev", "dev");
548 testutil::add_branch_on_bare(&container.join(".bare"), "feat/x", "x.txt");
549
550 let loops = open_loops(&container, "root", true).unwrap();
551 assert_eq!(loops.len(), 1);
552 assert_eq!(loops[0].repo_name, "my-app");
553 assert_eq!(loops[0].branch, "feat/x");
554 assert_eq!(loops[0].key(), "root/my-app/feat/x");
555 }
556
557 #[test]
558 fn open_loops_bare_root_repo_name_strips_dot_git_suffix() {
559 let tmp = tempfile::tempdir().unwrap();
560 let bare = tmp.path().join("foo.git");
561 testutil::init_bare_repo(&bare);
562 testutil::seed_bare_main(&bare);
563 testutil::add_branch_on_bare(&bare, "feat/y", "y.txt");
564
565 let loops = open_loops(&bare, "r", true).unwrap();
566 assert_eq!(loops[0].repo_name, "foo");
567 }
568
569 #[test]
570 fn open_loops_finds_unmerged_ignores_merged_and_default() {
571 let tmp = tempfile::tempdir().unwrap();
572 let repo = tmp.path().join("app");
573 testutil::init_repo(&repo);
574 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
575 testutil::git(&repo, &["branch", "merged"]); let loops = open_loops(&repo, "root", true).unwrap();
578 assert_eq!(loops.len(), 1);
579 let l = &loops[0];
580 assert_eq!(l.branch, "feat/x");
581 assert_eq!(l.repo_name, "app");
582 assert_eq!(l.root_label, "root");
583 assert_eq!(l.key(), "root/app/feat/x");
584 assert_eq!(l.ahead, Some(1));
585 assert_eq!(l.behind, Some(0));
586 assert_eq!(l.head_sha.len(), 40);
587 }
588
589 #[test]
590 fn open_loops_sets_repo_path_to_worktree_when_branch_checked_out() {
591 let tmp = tempfile::tempdir().unwrap();
592 let container = tmp.path().join("my-app");
593 testutil::init_bare_worktree_container(&container);
594 testutil::add_worktree_with_commit(&container, "feat-x", "feat/x", "x.txt");
595
596 let loops = open_loops(&container, "root", true).unwrap();
597 let lp = loops
598 .iter()
599 .find(|l| l.branch == "feat/x")
600 .expect("feat/x loop");
601 assert_same_path(&lp.repo_path, &container.join("feat-x"));
602 }
603
604 #[test]
605 fn open_loops_falls_back_to_container_when_branch_has_no_worktree() {
606 let tmp = tempfile::tempdir().unwrap();
607 let container = tmp.path().join("my-app");
608 testutil::init_bare_worktree_container(&container);
609 testutil::add_branch_on_bare(&container.join(".bare"), "feat/y", "y.txt");
611
612 let loops = open_loops(&container, "root", true).unwrap();
613 let lp = loops
614 .iter()
615 .find(|l| l.branch == "feat/y")
616 .expect("feat/y loop");
617 assert_eq!(lp.repo_path, container);
618 }
619
620 #[test]
621 fn open_loops_normal_repo_keeps_repo_path_as_repo_dir() {
622 let tmp = tempfile::tempdir().unwrap();
623 let repo = tmp.path().join("app");
624 testutil::init_repo(&repo);
625 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt"); let loops = open_loops(&repo, "root", true).unwrap();
627 assert_eq!(loops[0].branch, "feat/x");
628 assert_eq!(loops[0].repo_path, repo); }
630
631 #[test]
632 fn open_loops_skips_rev_list_when_need_ahead_behind_false() {
633 let tmp = tempfile::tempdir().unwrap();
634 let repo = tmp.path().join("app");
635 testutil::init_repo(&repo);
636 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
637
638 let loops = open_loops(&repo, "root", false).unwrap();
639 assert_eq!(loops.len(), 1);
640 assert_eq!(loops[0].ahead, None);
641 assert_eq!(loops[0].behind, None);
642 }
643
644 #[test]
645 fn open_loops_computes_ahead_behind_when_need_ahead_behind_true() {
646 let tmp = tempfile::tempdir().unwrap();
647 let repo = tmp.path().join("app");
648 testutil::init_repo(&repo);
649 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
650
651 let loops = open_loops(&repo, "root", true).unwrap();
652 assert_eq!(loops.len(), 1);
653 assert_eq!(loops[0].ahead, Some(1));
654 assert_eq!(loops[0].behind, Some(0));
655 }
656
657 #[test]
658 fn scan_repo_filter_pushdown_skips_non_matching_repos() {
659 let tmp = tempfile::tempdir().unwrap();
660 let api = tmp.path().join("api-service");
661 let web = tmp.path().join("web-app");
662 testutil::init_repo(&api);
663 testutil::init_repo(&web);
664 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
665 testutil::add_branch_with_commit(&web, "feat/web", "w.txt");
666
667 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
668 let (loops, _) = scan(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
669 assert_eq!(loops.len(), 1);
670 assert_eq!(loops[0].repo_name, "api-service");
671 assert_eq!(loops[0].branch, "feat/api");
672 }
673
674 #[test]
675 fn repo_name_hint_strips_dot_git_suffix() {
676 assert_eq!(repo_name_hint(std::path::Path::new("/srv/foo.git")), "foo");
677 }
678
679 #[test]
680 fn scan_repo_filter_is_case_insensitive() {
681 let tmp = tempfile::tempdir().unwrap();
682 let api = tmp.path().join("API-Service");
683 testutil::init_repo(&api);
684 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
685
686 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
687 let (loops, _) = scan(&[tmp.path().to_path_buf()], &labels, 4, false, Some("api"));
689 assert_eq!(loops.len(), 1);
690 assert_eq!(loops[0].repo_name, "API-Service");
691 }
692
693 #[test]
694 fn scan_repo_filter_matching_nothing_yields_no_loops() {
695 let tmp = tempfile::tempdir().unwrap();
696 let api = tmp.path().join("api-service");
697 testutil::init_repo(&api);
698 testutil::add_branch_with_commit(&api, "feat/api", "a.txt");
699
700 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
701 let (loops, warnings) = scan(
702 &[tmp.path().to_path_buf()],
703 &labels,
704 4,
705 false,
706 Some("zzz-nope"),
707 );
708 assert!(loops.is_empty());
709 assert!(
710 warnings.is_empty(),
711 "filtered-out repos must not warn: {warnings:?}"
712 );
713 }
714
715 #[test]
716 fn scan_aggregates_repos_and_reports_warning_without_aborting() {
717 let tmp = tempfile::tempdir().unwrap();
718 let good = tmp.path().join("good");
719 testutil::init_repo(&good);
720 testutil::add_branch_with_commit(&good, "feat/ok", "ok.txt");
721 let empty = tmp.path().join("empty");
723 std::fs::create_dir_all(&empty).unwrap();
724 testutil::git(&empty, &["init", "-b", "main"]);
725
726 let labels = vec![(tmp.path().to_path_buf(), "r".to_string())];
727 let (loops, warnings) = scan(&[tmp.path().to_path_buf()], &labels, 4, true, None);
728 assert_eq!(loops.len(), 1);
729 assert_eq!(loops[0].key(), "r/good/feat/ok");
730 assert_eq!(warnings.len(), 1);
731 assert!(warnings[0].contains("empty"));
732 }
733
734 #[test]
735 fn context_helpers_return_commits_and_window() {
736 let tmp = tempfile::tempdir().unwrap();
737 let repo = tmp.path().join("app");
738 testutil::init_repo(&repo);
739 testutil::add_branch_with_commit(&repo, "feat/x", "x.txt");
740
741 let log = git_log(&repo, "main", "feat/x").unwrap();
742 assert!(log.contains("wip feat/x"));
743 let stat = diffstat(&repo, "main", "feat/x").unwrap();
744 assert!(stat.contains("x.txt"));
745 let (start, end) = commit_window(&repo, "main", "feat/x").unwrap();
746 assert!(start <= end);
747 }
748
749 #[test]
750 fn default_branch_detects_master_fallback() {
751 let tmp = tempfile::tempdir().unwrap();
752 let repo = tmp.path();
753 testutil::git(repo, &["init", "-b", "master"]);
754 std::fs::write(repo.join("a.txt"), "a").unwrap();
755 testutil::git(repo, &["add", "."]);
756 testutil::git(repo, &["commit", "-m", "init"]);
757 assert_eq!(default_branch(repo).unwrap(), "master");
758 }
759
760 #[test]
761 fn default_branch_errors_without_main_or_master() {
762 let tmp = tempfile::tempdir().unwrap();
763 let repo = tmp.path();
764 testutil::git(repo, &["init", "-b", "trunk"]);
765 let err = default_branch(repo).unwrap_err();
767 assert!(err.to_string().contains("couldn't find the default branch"));
768 }
769
770 #[test]
771 fn git_common_dir_resolves_normal_and_bare_pointer() {
772 let tmp = tempfile::tempdir().unwrap();
773 let normal = tmp.path().join("app");
774 testutil::init_repo(&normal);
775 let normal_common = git_common_dir(&normal).unwrap();
776 assert!(normal_common.ends_with(".git"));
777
778 let container = tmp.path().join("container");
779 testutil::init_bare_worktree_container(&container);
780 let bare_common = git_common_dir(&container).unwrap();
781 assert!(bare_common.ends_with(".bare"));
782 }
783
784 #[test]
785 fn parse_worktree_porcelain_extracts_branches_and_flags() {
786 let out = "\
787worktree /home/u/app/main
788HEAD aaaaaaaa
789branch refs/heads/main
790
791worktree /home/u/app/feat-x
792HEAD bbbbbbbb
793branch refs/heads/feat/x
794
795worktree /home/u/app/detached
796HEAD cccccccc
797detached
798
799worktree /home/u/app/.bare
800bare
801";
802 let entries = parse_worktree_porcelain(out);
803 assert_eq!(entries.len(), 4);
804 assert_eq!(entries[0].branch.as_deref(), Some("main"));
805 assert_eq!(
806 entries[0].path,
807 std::path::PathBuf::from("/home/u/app/main")
808 );
809 assert_eq!(entries[1].branch.as_deref(), Some("feat/x")); assert_eq!(entries[2].branch, None); assert!(entries[3].bare);
812 assert_eq!(entries[3].branch, None);
813 }
814
815 #[test]
816 fn parse_worktree_porcelain_marks_prunable_and_handles_empty() {
817 assert!(parse_worktree_porcelain("").is_empty());
818 let out = "worktree /gone\nprunable gitdir file points to non-existent location\n";
819 let entries = parse_worktree_porcelain(out);
820 assert_eq!(entries.len(), 1);
821 assert!(entries[0].prunable);
822 assert_eq!(entries[0].branch, None);
823 }
824
825 #[test]
826 fn worktree_map_maps_checked_out_branches_to_paths() {
827 let tmp = tempfile::tempdir().unwrap();
828 let container = tmp.path().join("my-app");
829 testutil::init_bare_worktree_container(&container); testutil::add_named_worktree(&container, "dev", "dev"); let map = worktree_map(&container).unwrap();
833 assert_same_path(map.get("main").unwrap(), &container.join("main"));
834 assert_same_path(map.get("dev").unwrap(), &container.join("dev"));
835 assert!(!map.values().any(|p| p.ends_with(".bare")));
837 }
838
839 #[test]
840 fn worktree_map_errors_on_non_git_dir() {
841 let tmp = tempfile::tempdir().unwrap();
842 assert!(worktree_map(tmp.path()).is_err());
844 }
845
846 #[test]
847 fn parse_worktree_porcelain_ignores_lines_before_first_worktree() {
848 let out = "branch refs/heads/orphan\nHEAD deadbeef\nworktree /home/u/app/main\nbranch refs/heads/main\n";
849 let entries = parse_worktree_porcelain(out);
850 assert_eq!(entries.len(), 1);
851 assert_eq!(
852 entries[0].path,
853 std::path::PathBuf::from("/home/u/app/main")
854 );
855 assert_eq!(entries[0].branch.as_deref(), Some("main"));
856 }
857
858 #[test]
859 fn repo_name_from_common_dir_table() {
860 use std::path::Path;
861
862 let cases: &[(&str, &str)] = &[
863 ("/home/u/my-app/.bare", "my-app"),
864 ("/home/u/app/.git", "app"),
865 ("/srv/git/foo.git", "foo"),
866 ("/srv/git/myproject", "myproject"),
867 ];
868 for (common, want) in cases {
869 assert_eq!(
870 repo_name_from_common_dir(Path::new(common)),
871 *want,
872 "common_dir={common}"
873 );
874 }
875 }
876}