namespace ClaudeDo.Ui.Services; public sealed record RepoCandidate(string Name, string FullPath); public static class RepoScanner { private const int MaxDepth = 5; private static readonly HashSet SkipDirNames = new(StringComparer.OrdinalIgnoreCase) { "node_modules", "bin", "obj", ".git", ".vs", "packages", }; public static IReadOnlyList Scan(string parentFolder) { if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder)) return Array.Empty(); try { var info = new DirectoryInfo(parentFolder); if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return Array.Empty(); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return Array.Empty(); } var result = new List(); ScanDirectory(parentFolder, depth: 0, result); return result; } private static void ScanDirectory(string dir, int depth, List result) { if (IsRepo(dir)) { result.Add(new RepoCandidate(Path.GetFileName(dir), dir)); return; } if (depth >= MaxDepth) return; IEnumerable subdirs; try { subdirs = Directory.EnumerateDirectories(dir); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return; } foreach (var subdir in subdirs) { var name = Path.GetFileName(subdir); if (SkipDirNames.Contains(name)) continue; try { var info = new DirectoryInfo(subdir); if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) continue; } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { continue; } try { ScanDirectory(subdir, depth + 1, result); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } } } // A real clone has .git as a directory. Worktrees and submodules have it as a file // ("gitdir: ...") — those are not standalone repos and would flood the list. private static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git")); }