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] #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 }