RepoScanner.Scan() only guarded reparse points on subdirectories found during recursion, so a configured import root that is itself a junction walked straight through onto other drives. Apply the same FileAttributes.ReparsePoint check to the root before scanning. RepoImportModalViewModel.ScanAndAdd ran the full 5-level recursive scan synchronously on the UI thread; pointing the folder picker at a broad directory froze the app. Offload RepoScanner.Scan to Task.Run per folder and apply results back on the UI thread. Add a RepoScannerTests case that creates a real junction (mklink /J) at the scan root and asserts Scan returns empty.
173 lines
5.0 KiB
C#
173 lines
5.0 KiB
C#
using ClaudeDo.Ui.Services;
|
|
|
|
namespace ClaudeDo.Ui.Tests;
|
|
|
|
public sealed class RepoScannerTests : IDisposable
|
|
{
|
|
private readonly string _root =
|
|
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
|
|
|
|
public RepoScannerTests() => Directory.CreateDirectory(_root);
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch { }
|
|
}
|
|
|
|
private string MakeDir(string name)
|
|
{
|
|
var p = Path.Combine(_root, name);
|
|
Directory.CreateDirectory(p);
|
|
return p;
|
|
}
|
|
|
|
[Fact]
|
|
public void Scan_ReturnsSubfoldersWithGitDirectory()
|
|
{
|
|
var repo = MakeDir("repo-a");
|
|
Directory.CreateDirectory(Path.Combine(repo, ".git"));
|
|
|
|
var result = RepoScanner.Scan(_root);
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("repo-a", result[0].Name);
|
|
Assert.Equal(repo, result[0].FullPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scan_TreatsDotGitFileAsRepo()
|
|
{
|
|
var repo = MakeDir("worktree-repo");
|
|
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
|
|
|
|
var result = RepoScanner.Scan(_root);
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("worktree-repo", result[0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scan_IgnoresPlainFolders()
|
|
{
|
|
MakeDir("not-a-repo");
|
|
|
|
var result = RepoScanner.Scan(_root);
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scan_FindsRepoAtDepthTwo()
|
|
{
|
|
var nested = MakeDir(Path.Combine("outer", "inner"));
|
|
Directory.CreateDirectory(Path.Combine(nested, ".git"));
|
|
// outer itself has no .git
|
|
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scan_ReturnsEmptyForMissingFolder()
|
|
{
|
|
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
|
|
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scan_ReturnsEmptyWhenRootIsAReparsePoint()
|
|
{
|
|
var repo = MakeDir("real-repo");
|
|
Directory.CreateDirectory(Path.Combine(repo, ".git"));
|
|
|
|
var junction = Path.Combine(_root, "junction-to-real-repo");
|
|
var mklink = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "cmd.exe",
|
|
Arguments = $"/c mklink /J \"{junction}\" \"{repo}\"",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
})!;
|
|
mklink.WaitForExit();
|
|
Assert.True(Directory.Exists(junction), "Failed to create test junction: " + mklink.StandardError.ReadToEnd());
|
|
|
|
var result = RepoScanner.Scan(junction);
|
|
|
|
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
|
|
}
|