git_same/commands/
scan.rs1use crate::cli::ScanArgs;
4use crate::config::{Config, WorkspaceStore};
5use crate::errors::{AppError, Result};
6use crate::output::Output;
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9
10pub fn run(args: &ScanArgs, config_path: Option<&Path>, output: &Output) -> Result<()> {
12 let root = match &args.path {
13 Some(p) => p.clone(),
14 None => std::env::current_dir()
15 .map_err(|e| AppError::config(format!("Failed to resolve current directory: {}", e)))?,
16 };
17
18 let root = std::fs::canonicalize(&root).map_err(|e| {
19 AppError::config(format!(
20 "Failed to access scan root {}: {}",
21 root.display(),
22 e
23 ))
24 })?;
25 output.info(&format!(
26 "Scanning {} (depth {})",
27 root.display(),
28 args.depth
29 ));
30
31 let found = scan_for_workspaces(&root, args.depth);
32
33 if found.is_empty() {
34 output.info("No .git-same/ workspaces found.");
35 return Ok(());
36 }
37
38 let global = match config_path {
40 Some(path) => Config::load_from(path),
41 None => Config::load(),
42 }?;
43 let registered: std::collections::HashSet<PathBuf> = global
44 .workspaces
45 .iter()
46 .map(|p| {
47 let expanded = shellexpand::tilde(p);
48 std::fs::canonicalize(expanded.as_ref())
49 .unwrap_or_else(|_| PathBuf::from(expanded.as_ref()))
50 })
51 .collect();
52
53 let mut unregistered_count = 0usize;
54 let mut register_failures = Vec::new();
55 for ws_root in &found {
56 let is_registered = registered.contains(ws_root);
57 let tilde = crate::config::workspace::tilde_collapse_path(ws_root);
58 if is_registered {
59 output.plain(&format!(" [registered] {}", tilde));
60 } else {
61 output.plain(&format!(" [unregistered] {}", tilde));
62 unregistered_count += 1;
63
64 if args.register {
65 match WorkspaceStore::load(ws_root) {
66 Ok(ws) => {
67 let save_result = match config_path {
68 Some(path) => WorkspaceStore::save_with_registry_config_path(&ws, path),
69 None => WorkspaceStore::save(&ws),
70 };
71 match save_result {
72 Ok(()) => {
73 output.success(&format!(" Registered: {}", tilde));
74 unregistered_count = unregistered_count.saturating_sub(1);
75 }
76 Err(e) => {
77 output.warn(&format!(" Failed to register {}: {}", tilde, e));
78 register_failures.push(format!("{}: {}", tilde, e));
79 }
80 }
81 }
82 Err(e) => {
83 output.warn(&format!(" Skipping {}: {}", tilde, e));
84 register_failures.push(format!("{}: {}", tilde, e));
85 }
86 }
87 }
88 }
89 }
90
91 output.plain("");
92 output.info(&format!(
93 "Found {} workspace(s): {} registered, {} unregistered{}",
94 found.len(),
95 found.len() - unregistered_count,
96 unregistered_count,
97 if unregistered_count > 0 && !args.register {
98 " (use --register to add them)"
99 } else {
100 ""
101 }
102 ));
103
104 if !register_failures.is_empty() {
105 let first = register_failures
106 .first()
107 .map(String::as_str)
108 .unwrap_or("unknown error");
109 return Err(AppError::config(format!(
110 "Failed to register {} workspace(s). First error: {}",
111 register_failures.len(),
112 first
113 )));
114 }
115
116 Ok(())
117}
118
119fn scan_for_workspaces(root: &Path, max_depth: usize) -> Vec<PathBuf> {
121 let mut results = Vec::new();
122 let mut visited = HashSet::new();
123 scan_recursive(root, 0, max_depth, &mut results, &mut visited);
124 results.sort();
125 results.dedup();
126 results
127}
128
129fn scan_recursive(
130 dir: &Path,
131 depth: usize,
132 max_depth: usize,
133 results: &mut Vec<PathBuf>,
134 visited: &mut HashSet<PathBuf>,
135) {
136 if depth > max_depth {
137 return;
138 }
139
140 let canonical_dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
141 if !visited.insert(canonical_dir.clone()) {
142 return;
143 }
144
145 let config_path = WorkspaceStore::config_path(&canonical_dir);
147 if config_path.exists() {
148 results.push(canonical_dir);
149 return;
151 }
152
153 let Ok(entries) = std::fs::read_dir(&canonical_dir) else {
154 return;
155 };
156
157 for entry in entries.flatten() {
158 let Ok(file_type) = entry.file_type() else {
160 continue;
161 };
162 if !file_type.is_dir() {
163 continue;
164 }
165
166 let path = entry.path();
167 let name = entry.file_name().to_string_lossy().to_string();
168 if name.starts_with('.') {
170 continue;
171 }
172 scan_recursive(&path, depth + 1, max_depth, results, visited);
173 }
174}
175
176#[cfg(test)]
177#[path = "scan_tests.rs"]
178mod tests;