Skip to main content

git_same/commands/
scan.rs

1//! Scan command — find unregistered .git-same/ workspace folders.
2
3use 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
10/// Run the scan command.
11pub 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    // Load existing registry to flag already-registered workspaces
39    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
119/// Recursively scan for directories containing `.git-same/config.toml`.
120fn 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    // Check if this directory is a workspace root
146    let config_path = WorkspaceStore::config_path(&canonical_dir);
147    if config_path.exists() {
148        results.push(canonical_dir);
149        // Don't recurse into workspace directories
150        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        // Avoid traversing symlinks to directories.
159        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        // Skip hidden dirs (except .git-same itself is already handled above)
169        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;