From 7affb4c20411357f7adff74e50c2d8068795b9ec Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 6 Aug 2026 13:14:56 +0200 Subject: [PATCH] feat(ui): repo scan discovers nested repos in subfolders RepoScanner.Scan now recurses into subdirectories (max depth 5) instead of only checking the immediate children of the chosen folder. A found repo (.git as dir or file) is added without descending further; the selected folder itself is checked too. Skips node_modules/bin/obj/.git/ .vs/packages and reparse points (junctions/symlinks). Per-directory IOException/UnauthorizedAccessException no longer aborts the whole scan. --- src/ClaudeDo.Ui/Services/RepoScanner.cs | 62 +++++++++++++--- .../ClaudeDo.Ui.Tests.csproj | 1 + tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs | 73 ++++++++++++++++++- 3 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/ClaudeDo.Ui/Services/RepoScanner.cs b/src/ClaudeDo.Ui/Services/RepoScanner.cs index 8ac87858..099e4d3c 100644 --- a/src/ClaudeDo.Ui/Services/RepoScanner.cs +++ b/src/ClaudeDo.Ui/Services/RepoScanner.cs @@ -4,23 +4,63 @@ 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(); var result = new List(); - IEnumerable subdirs; - try { subdirs = Directory.EnumerateDirectories(parentFolder); } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { return Array.Empty(); } - - foreach (var dir in subdirs) - { - var gitPath = Path.Combine(dir, ".git"); - if (Directory.Exists(gitPath) || File.Exists(gitPath)) - result.Add(new RepoCandidate(Path.GetFileName(dir), dir)); - } + 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) + { } + } + } + + private static bool IsRepo(string dir) + { + var gitPath = Path.Combine(dir, ".git"); + return Directory.Exists(gitPath) || File.Exists(gitPath); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj b/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj index f2f78305..3cfb7ab3 100644 --- a/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj +++ b/tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs b/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs index d3e88aa1..cefe6988 100644 --- a/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs +++ b/tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs @@ -57,7 +57,7 @@ public sealed class RepoScannerTests : IDisposable } [Fact] - public void Scan_IsNotRecursive() + public void Scan_FindsRepoAtDepthTwo() { var nested = MakeDir(Path.Combine("outer", "inner")); Directory.CreateDirectory(Path.Combine(nested, ".git")); @@ -65,6 +65,47 @@ public sealed class RepoScannerTests : IDisposable var result = RepoScanner.Scan(_root); + Assert.Single(result); + Assert.Equal("inner", result[0].Name); + Assert.Equal(nested, result[0].FullPath); + } + + [Fact] + public void Scan_FindsRepoAtDepthThree() + { + var nested = MakeDir(Path.Combine("customers", "projX", "repo")); + Directory.CreateDirectory(Path.Combine(nested, ".git")); + + var result = RepoScanner.Scan(_root); + + Assert.Single(result); + Assert.Equal("repo", result[0].Name); + Assert.Equal(nested, result[0].FullPath); + } + + [Fact] + public void Scan_DoesNotDescendBelowAFoundRepo() + { + var repo = MakeDir("repo-a"); + Directory.CreateDirectory(Path.Combine(repo, ".git")); + var submodule = Path.Combine(repo, "vendor", "nested-repo"); + Directory.CreateDirectory(submodule); + Directory.CreateDirectory(Path.Combine(submodule, ".git")); + + var result = RepoScanner.Scan(_root); + + Assert.Single(result); + Assert.Equal("repo-a", result[0].Name); + } + + [Fact] + public void Scan_SkipsNodeModules() + { + var nodeModules = MakeDir(Path.Combine("node_modules", "some-package")); + Directory.CreateDirectory(Path.Combine(nodeModules, ".git")); + + var result = RepoScanner.Scan(_root); + Assert.Empty(result); } @@ -75,4 +116,34 @@ public sealed class RepoScannerTests : IDisposable Assert.Empty(result); } + + [Fact] +#pragma warning disable CA1416 // ACL manipulation is Windows-only; this test targets the Windows dev/CI environment + public void Scan_ContinuesAfterUnreadableSubdirectory() + { + var repo = MakeDir("repo-a"); + Directory.CreateDirectory(Path.Combine(repo, ".git")); + var locked = new DirectoryInfo(MakeDir("locked")); + var denyRule = new System.Security.AccessControl.FileSystemAccessRule( + Environment.UserName, + System.Security.AccessControl.FileSystemRights.ListDirectory, + System.Security.AccessControl.AccessControlType.Deny); + var acl = locked.GetAccessControl(); + acl.AddAccessRule(denyRule); + try + { + locked.SetAccessControl(acl); + + var result = RepoScanner.Scan(_root); + + Assert.Single(result); + Assert.Equal("repo-a", result[0].Name); + } + finally + { + acl.RemoveAccessRule(denyRule); + locked.SetAccessControl(acl); + } + } +#pragma warning restore CA1416 }