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.
This commit is contained in:
@@ -4,23 +4,63 @@ public sealed record RepoCandidate(string Name, string FullPath);
|
|||||||
|
|
||||||
public static class RepoScanner
|
public static class RepoScanner
|
||||||
{
|
{
|
||||||
|
private const int MaxDepth = 5;
|
||||||
|
|
||||||
|
private static readonly HashSet<string> SkipDirNames = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"node_modules", "bin", "obj", ".git", ".vs", "packages",
|
||||||
|
};
|
||||||
|
|
||||||
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
|
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
|
||||||
return Array.Empty<RepoCandidate>();
|
return Array.Empty<RepoCandidate>();
|
||||||
|
|
||||||
var result = new List<RepoCandidate>();
|
var result = new List<RepoCandidate>();
|
||||||
IEnumerable<string> subdirs;
|
ScanDirectory(parentFolder, depth: 0, result);
|
||||||
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
|
|
||||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
|
||||||
{ return Array.Empty<RepoCandidate>(); }
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScanDirectory(string dir, int depth, List<RepoCandidate> result)
|
||||||
|
{
|
||||||
|
if (IsRepo(dir))
|
||||||
|
{
|
||||||
|
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= MaxDepth)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IEnumerable<string> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
|
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public sealed class RepoScannerTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Scan_IsNotRecursive()
|
public void Scan_FindsRepoAtDepthTwo()
|
||||||
{
|
{
|
||||||
var nested = MakeDir(Path.Combine("outer", "inner"));
|
var nested = MakeDir(Path.Combine("outer", "inner"));
|
||||||
Directory.CreateDirectory(Path.Combine(nested, ".git"));
|
Directory.CreateDirectory(Path.Combine(nested, ".git"));
|
||||||
@@ -65,6 +65,47 @@ public sealed class RepoScannerTests : IDisposable
|
|||||||
|
|
||||||
var result = RepoScanner.Scan(_root);
|
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);
|
Assert.Empty(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,4 +116,34 @@ public sealed class RepoScannerTests : IDisposable
|
|||||||
|
|
||||||
Assert.Empty(result);
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user