1use anyhow::{bail, Result};
2use colored::Colorize;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub struct PrettyEntry {
10 pub path: PathBuf,
12 pub display_name: String,
14 pub base_name: String,
16 pub worktree_of: Option<String>,
18 sort_key: SortKey,
19}
20
21type SortKey = (String, u8, String);
24
25pub fn is_worktree(path: &Path) -> bool {
27 let git_entry = path.join(".git");
28 git_entry.is_file()
29}
30
31pub fn worktree_main_repo_name(path: &Path) -> Result<String> {
35 let git_file = path.join(".git");
36 let content = fs::read_to_string(&git_file)?;
37 let gitdir = content.strip_prefix("gitdir: ").unwrap_or(&content).trim();
38 let gitdir_path = PathBuf::from(gitdir);
41 let main_git_dir = gitdir_path
43 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .and_then(|p| p.file_name())
47 .map(|n| n.to_string_lossy().to_string());
48 match main_git_dir {
49 Some(name) => Ok(name),
50 None => bail!(
51 "could not determine main repo from worktree at {}",
52 path.display()
53 ),
54 }
55}
56
57pub fn build_pretty_names(paths: &[PathBuf]) -> Vec<PrettyEntry> {
64 let mut entries: Vec<(PathBuf, String, Option<String>, SortKey)> = Vec::new();
66
67 for path in paths {
68 let basename = path
69 .file_name()
70 .map(|n| n.to_string_lossy().to_string())
71 .unwrap_or_default();
72
73 if is_worktree(path) {
74 if let Ok(main_name) = worktree_main_repo_name(path) {
75 let prefix = format!("{}--", main_name);
76 let short_name = if basename.starts_with(&prefix) {
77 basename[prefix.len()..].to_string()
78 } else {
79 basename.clone()
80 };
81 let sort_key = (main_name.to_lowercase(), 1, short_name.to_lowercase());
82 entries.push((path.clone(), short_name, Some(main_name), sort_key));
83 continue;
84 }
85 }
86
87 let sort_key = (basename.to_lowercase(), 0, String::new());
88 entries.push((path.clone(), basename, None, sort_key));
89 }
90
91 let mut name_counts: HashMap<String, Vec<usize>> = HashMap::new();
93 for (i, (_, name, _, _)) in entries.iter().enumerate() {
94 name_counts.entry(name.clone()).or_default().push(i);
95 }
96
97 let mut result: Vec<PrettyEntry> = entries
98 .iter()
99 .map(|(path, name, worktree_of, sort_key)| {
100 let display = match worktree_of {
101 Some(main_name) => format!("{} @{}", name, main_name),
102 None => name.clone(),
103 };
104 PrettyEntry {
105 path: path.clone(),
106 display_name: display,
107 base_name: name.clone(),
108 worktree_of: worktree_of.clone(),
109 sort_key: sort_key.clone(),
110 }
111 })
112 .collect();
113
114 for indices in name_counts.values() {
116 if indices.len() <= 1 {
117 continue;
118 }
119
120 let paths_for_collision: Vec<&Path> =
122 indices.iter().map(|&i| entries[i].0.as_path()).collect();
123 let suffixes = shortest_unique_suffixes(&paths_for_collision);
124
125 for (j, &idx) in indices.iter().enumerate() {
126 let (_, ref base_name, ref worktree_of, _) = entries[idx];
127 let display = match worktree_of {
128 Some(main_name) => {
129 format!("{} ({}) @{}", base_name, suffixes[j], main_name)
130 }
131 None => format!("{} ({})", base_name, suffixes[j]),
132 };
133 result[idx].display_name = display;
134 }
135 }
136
137 result.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
139
140 result
141}
142
143fn shortest_unique_suffixes(paths: &[&Path]) -> Vec<String> {
152 let parents: Vec<Vec<String>> = paths
153 .iter()
154 .map(|p| {
155 let parent = p.parent().unwrap_or(Path::new(""));
156 parent
157 .components()
158 .map(|c| c.as_os_str().to_string_lossy().to_string())
159 .collect::<Vec<_>>()
160 })
161 .collect();
162
163 let n = paths.len();
164 let mut suffixes = vec![String::new(); n];
165
166 let max_components = parents.iter().map(|p| p.len()).max().unwrap_or(0);
168
169 for depth in 1..=max_components {
170 let tails: Vec<String> = parents
171 .iter()
172 .map(|components| {
173 let start = components.len().saturating_sub(depth);
174 components[start..].join("/")
175 })
176 .collect();
177
178 let mut seen: HashMap<&str, Vec<usize>> = HashMap::new();
180 for (i, tail) in tails.iter().enumerate() {
181 seen.entry(tail.as_str()).or_default().push(i);
182 }
183
184 for (i, tail) in tails.iter().enumerate() {
185 if suffixes[i].is_empty() && seen[tail.as_str()].len() == 1 {
186 suffixes[i] = tail.clone();
187 }
188 }
189
190 if suffixes.iter().all(|s| !s.is_empty()) {
191 break;
192 }
193 }
194
195 suffixes
196}
197
198fn tree_name(entry: &PrettyEntry) -> &str {
203 if let Some(ref parent) = entry.worktree_of {
204 let suffix = format!(" @{}", parent);
205 entry
206 .display_name
207 .strip_suffix(&suffix)
208 .unwrap_or(&entry.display_name)
209 } else {
210 &entry.display_name
211 }
212}
213
214pub fn build_tree_output(entries: &[PrettyEntry]) -> Vec<String> {
219 let mut lines = Vec::new();
220 let mut i = 0;
221 while i < entries.len() {
222 let entry = &entries[i];
223 if entry.worktree_of.is_some() {
224 let prefix = "└─ ".dimmed();
226 lines.push(format!("{}{}", prefix, tree_name(entry).green()));
227 i += 1;
228 continue;
229 }
230
231 lines.push(format!("{}", tree_name(entry).bold()));
233 let repo_name = &entry.base_name;
234 i += 1;
235
236 let wt_start = i;
238 while i < entries.len() && entries[i].worktree_of.as_deref() == Some(repo_name) {
239 i += 1;
240 }
241 let wt_end = i;
242
243 let wt_count = wt_end - wt_start;
244 for (j, entry) in entries[wt_start..wt_end].iter().enumerate() {
245 let is_last = j == wt_count - 1;
246 let connector = if is_last { "└─ " } else { "├─ " };
247 lines.push(format!(
248 "{}{}",
249 connector.dimmed(),
250 tree_name(entry).green()
251 ));
252 }
253 }
254 lines
255}
256
257pub fn prettify(path: &Path, paths: &[PathBuf]) -> Result<String> {
262 let canonical = fs::canonicalize(path)?;
263 let entries = build_pretty_names(paths);
264 for entry in &entries {
265 if let Ok(entry_canonical) = fs::canonicalize(&entry.path) {
266 if entry_canonical == canonical {
267 return Ok(entry.display_name.clone());
268 }
269 }
270 }
271 bail!("no project matches path '{}'", path.display())
272}
273
274pub fn resolve(pretty_name: &str, paths: &[PathBuf]) -> Result<PathBuf> {
279 let entries = build_pretty_names(paths);
280 let matches: Vec<&PrettyEntry> = entries
281 .iter()
282 .filter(|e| e.display_name == pretty_name)
283 .collect();
284
285 match matches.len() {
286 0 => bail!("no project matches '{}'", pretty_name),
287 1 => Ok(matches[0].path.clone()),
288 _ => bail!(
289 "ambiguous name '{}' matches {} projects",
290 pretty_name,
291 matches.len()
292 ),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::fs;
300 use tempfile::TempDir;
301
302 fn make_git_repo(path: &Path) {
303 fs::create_dir_all(path).unwrap();
304 fs::create_dir(path.join(".git")).unwrap();
305 }
306
307 fn make_git_worktree(path: &Path, main_repo: &Path) {
308 fs::create_dir_all(path).unwrap();
309 let worktree_name = path.file_name().unwrap().to_string_lossy();
310 let gitdir = main_repo
311 .join(".git")
312 .join("worktrees")
313 .join(worktree_name.as_ref());
314 fs::create_dir_all(&gitdir).unwrap();
315 fs::write(path.join(".git"), format!("gitdir: {}", gitdir.display())).unwrap();
316 }
317
318 #[test]
319 fn test_is_worktree_regular_repo() {
320 let tmp = TempDir::new().unwrap();
321 let repo = tmp.path().join("repo");
322 make_git_repo(&repo);
323 assert!(!is_worktree(&repo));
324 }
325
326 #[test]
327 fn test_is_worktree_actual_worktree() {
328 let tmp = TempDir::new().unwrap();
329 let main_repo = tmp.path().join("main");
330 make_git_repo(&main_repo);
331 let wt = tmp.path().join("main--feature");
332 make_git_worktree(&wt, &main_repo);
333 assert!(is_worktree(&wt));
334 }
335
336 #[test]
337 fn test_worktree_main_repo_name() {
338 let tmp = TempDir::new().unwrap();
339 let main_repo = tmp.path().join("cargostack-backend");
340 make_git_repo(&main_repo);
341 let wt = tmp.path().join("cargostack-backend--fix-branch");
342 make_git_worktree(&wt, &main_repo);
343
344 let name = worktree_main_repo_name(&wt).unwrap();
345 assert_eq!(name, "cargostack-backend");
346 }
347
348 #[test]
349 fn test_pretty_simple_repos() {
350 let tmp = TempDir::new().unwrap();
351 let repo_a = tmp.path().join("alpha");
352 let repo_b = tmp.path().join("beta");
353 make_git_repo(&repo_a);
354 make_git_repo(&repo_b);
355
356 let paths = vec![repo_a, repo_b];
357 let entries = build_pretty_names(&paths);
358 assert_eq!(entries[0].display_name, "alpha");
359 assert_eq!(entries[1].display_name, "beta");
360 }
361
362 #[test]
363 fn test_pretty_worktree_annotation() {
364 let tmp = TempDir::new().unwrap();
365 let main_repo = tmp.path().join("cargostack-backend");
366 make_git_repo(&main_repo);
367 let wt = tmp.path().join("cargostack-backend--fix-branch");
368 make_git_worktree(&wt, &main_repo);
369
370 let paths = vec![main_repo, wt];
371 let entries = build_pretty_names(&paths);
372 assert_eq!(entries[0].display_name, "cargostack-backend");
373 assert_eq!(entries[1].display_name, "fix-branch @cargostack-backend");
374 }
375
376 #[test]
377 fn test_pretty_collision_disambiguation() {
378 let tmp = TempDir::new().unwrap();
379 let mnemo_ws = tmp.path().join("Workspace").join("mnemo");
380 let mnemo_docs = tmp.path().join("Documents").join("projects").join("mnemo");
381 make_git_repo(&mnemo_ws);
382 make_git_repo(&mnemo_docs);
383
384 let paths = vec![mnemo_ws, mnemo_docs];
385 let entries = build_pretty_names(&paths);
386
387 assert!(
389 entries[0].display_name.contains("Workspace"),
390 "expected Workspace disambiguation, got: {}",
391 entries[0].display_name
392 );
393 assert!(
394 entries[1].display_name.contains("projects"),
395 "expected projects disambiguation, got: {}",
396 entries[1].display_name
397 );
398 }
399
400 #[test]
401 fn test_pretty_collision_shortest_suffix() {
402 let tmp = TempDir::new().unwrap();
403 let mnemo_a = tmp.path().join("a").join("projects").join("mnemo");
405 let mnemo_b = tmp.path().join("b").join("projects").join("mnemo");
406 make_git_repo(&mnemo_a);
407 make_git_repo(&mnemo_b);
408
409 let paths = vec![mnemo_a, mnemo_b];
410 let entries = build_pretty_names(&paths);
411
412 assert!(
414 entries[0].display_name.contains("a/projects") || entries[0].display_name.contains("a"),
415 "expected 'a' disambiguation, got: {}",
416 entries[0].display_name
417 );
418 assert!(
419 entries[1].display_name.contains("b/projects") || entries[1].display_name.contains("b"),
420 "expected 'b' disambiguation, got: {}",
421 entries[1].display_name
422 );
423 }
424
425 #[test]
426 fn test_pretty_no_collision_no_suffix() {
427 let tmp = TempDir::new().unwrap();
428 let alpha = tmp.path().join("alpha");
429 let beta = tmp.path().join("beta");
430 make_git_repo(&alpha);
431 make_git_repo(&beta);
432
433 let paths = vec![alpha, beta];
434 let entries = build_pretty_names(&paths);
435 assert!(!entries[0].display_name.contains('('));
436 assert!(!entries[1].display_name.contains('('));
437 }
438
439 #[test]
440 fn test_pretty_worktree_with_collision() {
441 let tmp = TempDir::new().unwrap();
443
444 let feature_repo = tmp.path().join("Workspace").join("feature");
445 make_git_repo(&feature_repo);
446
447 let main_repo = tmp.path().join("worktrees").join("myapp");
448 make_git_repo(&main_repo);
449
450 let wt = tmp.path().join("worktrees").join("myapp--feature");
451 make_git_worktree(&wt, &main_repo);
452
453 let paths = vec![feature_repo, wt];
454 let entries = build_pretty_names(&paths);
455
456 let wt_entry = &entries[1];
459 assert!(
460 wt_entry.display_name.contains("@myapp"),
461 "expected worktree annotation, got: {}",
462 wt_entry.display_name
463 );
464 assert!(
465 wt_entry.display_name.contains('('),
466 "expected disambiguation, got: {}",
467 wt_entry.display_name
468 );
469 }
470
471 #[test]
472 fn test_prettify_simple() {
473 let tmp = TempDir::new().unwrap();
474 let repo_a = tmp.path().join("alpha");
475 let repo_b = tmp.path().join("beta");
476 make_git_repo(&repo_a);
477 make_git_repo(&repo_b);
478
479 let paths = vec![repo_a.clone(), repo_b];
480 let result = prettify(&repo_a, &paths).unwrap();
481 assert_eq!(result, "alpha");
482 }
483
484 #[test]
485 fn test_prettify_worktree() {
486 let tmp = TempDir::new().unwrap();
487 let main_repo = tmp.path().join("myapp");
488 make_git_repo(&main_repo);
489 let wt = tmp.path().join("myapp--feature");
490 make_git_worktree(&wt, &main_repo);
491
492 let paths = vec![main_repo, wt.clone()];
493 let result = prettify(&wt, &paths).unwrap();
494 assert_eq!(result, "feature @myapp");
495 }
496
497 #[test]
498 fn test_prettify_no_match() {
499 let tmp = TempDir::new().unwrap();
500 let repo = tmp.path().join("alpha");
501 let other = tmp.path().join("nonexistent");
502 make_git_repo(&repo);
503 fs::create_dir_all(&other).unwrap();
504
505 let paths = vec![repo];
506 let result = prettify(&other, &paths);
507 assert!(result.is_err());
508 }
509
510 #[test]
511 fn test_prettify_roundtrip() {
512 let tmp = TempDir::new().unwrap();
513 let repo_a = tmp.path().join("alpha");
514 let repo_b = tmp.path().join("beta");
515 make_git_repo(&repo_a);
516 make_git_repo(&repo_b);
517
518 let paths = vec![repo_a.clone(), repo_b];
519 let name = prettify(&repo_a, &paths).unwrap();
520 let resolved = resolve(&name, &paths).unwrap();
521 assert_eq!(resolved, repo_a);
522 }
523
524 #[test]
525 fn test_resolve_exact_match() {
526 let tmp = TempDir::new().unwrap();
527 let repo_a = tmp.path().join("alpha");
528 let repo_b = tmp.path().join("beta");
529 make_git_repo(&repo_a);
530 make_git_repo(&repo_b);
531
532 let paths = vec![repo_a.clone(), repo_b];
533 let result = resolve("alpha", &paths).unwrap();
534 assert_eq!(result, repo_a);
535 }
536
537 #[test]
538 fn test_resolve_worktree() {
539 let tmp = TempDir::new().unwrap();
540 let main_repo = tmp.path().join("myapp");
541 make_git_repo(&main_repo);
542 let wt = tmp.path().join("myapp--feature");
543 make_git_worktree(&wt, &main_repo);
544
545 let paths = vec![main_repo, wt.clone()];
546 let result = resolve("feature @myapp", &paths).unwrap();
547 assert_eq!(result, wt);
548 }
549
550 #[test]
551 fn test_resolve_no_match() {
552 let tmp = TempDir::new().unwrap();
553 let repo = tmp.path().join("alpha");
554 make_git_repo(&repo);
555
556 let paths = vec![repo];
557 let result = resolve("nonexistent", &paths);
558 assert!(result.is_err());
559 }
560
561 #[test]
562 fn test_resolve_disambiguated_name() {
563 let tmp = TempDir::new().unwrap();
564 let mnemo_ws = tmp.path().join("Workspace").join("mnemo");
565 let mnemo_docs = tmp.path().join("Documents").join("mnemo");
566 make_git_repo(&mnemo_ws);
567 make_git_repo(&mnemo_docs);
568
569 let paths = vec![mnemo_ws.clone(), mnemo_docs.clone()];
570 let entries = build_pretty_names(&paths);
571
572 let result = resolve(&entries[0].display_name, &paths).unwrap();
574 assert_eq!(result, mnemo_ws);
575
576 let result = resolve(&entries[1].display_name, &paths).unwrap();
577 assert_eq!(result, mnemo_docs);
578 }
579
580 #[test]
581 fn test_shortest_unique_suffixes_simple() {
582 let a = PathBuf::from("/home/user/Workspace/mnemo");
583 let b = PathBuf::from("/home/user/Documents/projects/mnemo");
584 let paths: Vec<&Path> = vec![a.as_path(), b.as_path()];
585 let suffixes = shortest_unique_suffixes(&paths);
586 assert_eq!(suffixes[0], "Workspace");
587 assert_eq!(suffixes[1], "projects");
588 }
589
590 #[test]
591 fn test_shortest_unique_suffixes_deeper() {
592 let a = PathBuf::from("/x/a/shared/mnemo");
593 let b = PathBuf::from("/x/b/shared/mnemo");
594 let paths: Vec<&Path> = vec![a.as_path(), b.as_path()];
595 let suffixes = shortest_unique_suffixes(&paths);
596 assert_eq!(suffixes[0], "a/shared");
598 assert_eq!(suffixes[1], "b/shared");
599 }
600
601 #[test]
602 fn test_sorted_worktrees_after_main_repo() {
603 let tmp = TempDir::new().unwrap();
604
605 let zebra = tmp.path().join("zebra");
606 make_git_repo(&zebra);
607
608 let alpha = tmp.path().join("alpha");
609 make_git_repo(&alpha);
610
611 let myapp = tmp.path().join("myapp");
612 make_git_repo(&myapp);
613
614 let wt_feature = tmp.path().join("myapp--feature");
615 make_git_worktree(&wt_feature, &myapp);
616
617 let wt_bugfix = tmp.path().join("myapp--bugfix");
618 make_git_worktree(&wt_bugfix, &myapp);
619
620 let paths = vec![zebra, wt_feature, alpha, wt_bugfix, myapp];
622 let entries = build_pretty_names(&paths);
623 let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
624
625 assert_eq!(
626 names,
627 vec!["alpha", "myapp", "bugfix @myapp", "feature @myapp", "zebra",]
628 );
629 }
630
631 #[test]
632 fn test_sorted_case_insensitive() {
633 let tmp = TempDir::new().unwrap();
634 let upper = tmp.path().join("Zebra");
635 let lower = tmp.path().join("alpha");
636 make_git_repo(&upper);
637 make_git_repo(&lower);
638
639 let paths = vec![upper, lower];
640 let entries = build_pretty_names(&paths);
641 let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
642 assert_eq!(names, vec!["alpha", "Zebra"]);
643 }
644
645 #[test]
646 fn test_three_way_collision() {
647 let tmp = TempDir::new().unwrap();
648 let a = tmp.path().join("x").join("mnemo");
649 let b = tmp.path().join("y").join("mnemo");
650 let c = tmp.path().join("z").join("mnemo");
651 make_git_repo(&a);
652 make_git_repo(&b);
653 make_git_repo(&c);
654
655 let paths = vec![a, b, c];
656 let entries = build_pretty_names(&paths);
657
658 let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
660 assert_eq!(names.len(), 3);
661 assert_ne!(names[0], names[1]);
662 assert_ne!(names[1], names[2]);
663 assert_ne!(names[0], names[2]);
664 }
665
666 #[test]
667 fn test_tree_output_no_worktrees() {
668 let tmp = TempDir::new().unwrap();
669 let alpha = tmp.path().join("alpha");
670 let beta = tmp.path().join("beta");
671 make_git_repo(&alpha);
672 make_git_repo(&beta);
673
674 let paths = vec![alpha, beta];
675 let entries = build_pretty_names(&paths);
676 let lines = build_tree_output(&entries);
677 let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
679 assert_eq!(plain, vec!["alpha", "beta"]);
680 }
681
682 #[test]
683 fn test_tree_output_with_worktrees() {
684 let tmp = TempDir::new().unwrap();
685 let myapp = tmp.path().join("myapp");
686 make_git_repo(&myapp);
687 let wt_bug = tmp.path().join("myapp--bugfix");
688 make_git_worktree(&wt_bug, &myapp);
689 let wt_feat = tmp.path().join("myapp--feature");
690 make_git_worktree(&wt_feat, &myapp);
691 let zebra = tmp.path().join("zebra");
692 make_git_repo(&zebra);
693
694 let paths = vec![myapp, wt_bug, wt_feat, zebra];
695 let entries = build_pretty_names(&paths);
696 let lines = build_tree_output(&entries);
697 let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
698 assert_eq!(plain, vec!["myapp", "├─ bugfix", "└─ feature", "zebra"]);
699 }
700
701 #[test]
702 fn test_tree_output_single_worktree() {
703 let tmp = TempDir::new().unwrap();
704 let myapp = tmp.path().join("myapp");
705 make_git_repo(&myapp);
706 let wt = tmp.path().join("myapp--feature");
707 make_git_worktree(&wt, &myapp);
708
709 let paths = vec![myapp, wt];
710 let entries = build_pretty_names(&paths);
711 let lines = build_tree_output(&entries);
712 let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
713 assert_eq!(plain, vec!["myapp", "└─ feature"]);
714 }
715
716 #[test]
717 fn test_tree_output_disambiguated_repos() {
718 let tmp = TempDir::new().unwrap();
719 let notes_personal = tmp.path().join("personal").join("notes");
720 let notes_work = tmp.path().join("work").join("notes");
721 make_git_repo(¬es_personal);
722 make_git_repo(¬es_work);
723
724 let paths = vec![notes_personal, notes_work];
725 let entries = build_pretty_names(&paths);
726 let lines = build_tree_output(&entries);
727 let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
728
729 assert_eq!(plain.len(), 2);
730 assert!(
731 plain[0].contains("personal"),
732 "expected disambiguation, got: {}",
733 plain[0]
734 );
735 assert!(
736 plain[1].contains("work"),
737 "expected disambiguation, got: {}",
738 plain[1]
739 );
740 }
741
742 fn strip_ansi(s: &str) -> String {
744 let mut result = String::new();
745 let mut chars = s.chars();
746 while let Some(c) = chars.next() {
747 if c == '\x1b' {
748 for inner in chars.by_ref() {
750 if inner == 'm' {
751 break;
752 }
753 }
754 } else {
755 result.push(c);
756 }
757 }
758 result
759 }
760}