diff --git a/ProjectDirector.Test/OwnerSyncTests.cs b/ProjectDirector.Test/OwnerSyncTests.cs new file mode 100644 index 0000000..436e6fb --- /dev/null +++ b/ProjectDirector.Test/OwnerSyncTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.Test; + +using System; +using System.Collections.Generic; +using System.IO; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers an owner scan meeting a repository that Scan Dev Dir already found cloned elsewhere. +/// +/// +/// Scan Dev Dir adds each clone's owner to the owners it scans, so the documented flow is Scan Dev +/// Dir followed by Scan GitHub Owners. An owner scan that moved such a repository to +/// <dev>/<owner>/<repo> pointed it at a folder that does not exist and left +/// the old clone recorded, so the repository fetched nothing and offered git actions that all failed. +/// +[TestClass] +public sealed class OwnerSyncTests +{ + private static FullyQualifiedGitHubRepoName Name(string repoName) => + FullyQualifiedGitHubRepoName.Create($"ktsu-dev.{repoName}"); + + private static FullyQualifiedLocalRepoPath LocalPath(string path) => + FullyQualifiedLocalRepoPath.Create(path); + + private static GitHubRepository Repository(string localPath, string repoName) => new() + { + OwnerName = GitHubOwnerName.Create("ktsu-dev"), + RepoName = GitHubRepoName.Create(repoName), + LocalPath = LocalPath(localPath), + }; + + private static string CreateDevDirectory() => + Directory.CreateDirectory(Path.Join(Path.GetTempPath(), $"ktsu_pd_{Guid.NewGuid():N}")).FullName; + + [TestMethod] + public void AnOwnerScanKeepsTheFolderARepositoryIsAlreadyClonedIn() + { + string dev = CreateDevDirectory(); + try + { + string clone = Path.Join(dev, "ProjectDirector"); + Assert.IsTrue(GitCli.Run("init", clone).Succeeded, "git init failed."); + Dictionary repos = new() + { + [Name("ProjectDirector")] = Repository(clone, "ProjectDirector"), + }; + + FullyQualifiedLocalRepoPath chosen = ProjectDirector.ChooseSyncedLocalPath( + repos, Name("ProjectDirector"), LocalPath(Path.Join(dev, "ktsu-dev", "ProjectDirector"))); + + Assert.AreEqual(LocalPath(clone), chosen); + } + finally + { + TryDeleteDirectory(dev); + } + } + + [TestMethod] + public void AnOwnerScanUsesTheConventionalFolderForAnUnknownRepository() + { + FullyQualifiedLocalRepoPath conventional = LocalPath(Path.Join(Path.GetTempPath(), "ktsu-dev", "New")); + + FullyQualifiedLocalRepoPath chosen = ProjectDirector.ChooseSyncedLocalPath(new Dictionary(), Name("New"), conventional); + + Assert.AreEqual(conventional, chosen); + } + + [TestMethod] + public void AnOwnerScanUsesTheConventionalFolderWhenTheKnownOneIsNotAClone() + { + string missing = Path.Join(Path.GetTempPath(), $"ktsu_pd_{Guid.NewGuid():N}", "Gone"); + Dictionary repos = new() + { + [Name("Gone")] = Repository(missing, "Gone"), + }; + FullyQualifiedLocalRepoPath conventional = LocalPath(Path.Join(Path.GetTempPath(), "ktsu-dev", "Gone")); + + FullyQualifiedLocalRepoPath chosen = ProjectDirector.ChooseSyncedLocalPath(repos, Name("Gone"), conventional); + + Assert.AreEqual(conventional, chosen); + } + + [TestMethod] + public void ACloneRecordedAtAPathItsRepositoryNoLongerUsesIsPruned() + { + string oldPath = Path.Join(Path.GetTempPath(), "dev", "ProjectDirector"); + string newPath = Path.Join(Path.GetTempPath(), "dev", "ktsu-dev", "ProjectDirector"); + Dictionary repos = new() + { + [Name("ProjectDirector")] = Repository(newPath, "ProjectDirector"), + }; + Dictionary cloned = new() + { + [LocalPath(oldPath)] = Name("ProjectDirector"), + }; + + Assert.IsTrue(ProjectDirector.PruneStaleClonedRepos(cloned, repos)); + Assert.IsEmpty(cloned); + } + + [TestMethod] + public void ACloneOfAnUnknownRepositoryIsPruned() + { + Dictionary cloned = new() + { + [LocalPath(Path.Join(Path.GetTempPath(), "dev", "Orphan"))] = Name("Orphan"), + }; + + Assert.IsTrue(ProjectDirector.PruneStaleClonedRepos(cloned, new Dictionary())); + Assert.IsEmpty(cloned); + } + + [TestMethod] + public void ACloneRecordedAtItsRepositorysPathIsKept() + { + string path = Path.Join(Path.GetTempPath(), "dev", "ProjectDirector"); + Dictionary repos = new() + { + [Name("ProjectDirector")] = Repository(path, "ProjectDirector"), + }; + Dictionary cloned = new() + { + [LocalPath(path)] = Name("ProjectDirector"), + }; + + Assert.IsFalse(ProjectDirector.PruneStaleClonedRepos(cloned, repos)); + Assert.HasCount(1, cloned); + } + + private static void TryDeleteDirectory(string path) + { + try + { + // Git marks objects read-only, which blocks a plain recursive delete on Windows. + foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + // A best-effort cleanup of a temp directory is not worth failing a test over. + } + catch (UnauthorizedAccessException) + { + // As above. + } + } +} diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 6a244f0..f7cd061 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -1012,8 +1012,11 @@ private void SyncGitHubRepoInfoForOwner(GitHubOwnerName owner) foreach (Repository remoteRepo in remoteRepos) { - FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(Options.DevDirectory / RelativeDirectoryPath.Create(remoteRepo.FullName)); FullyQualifiedGitHubRepoName repoName = GetFullyQualifiedRepoName(remoteRepo); + FullyQualifiedLocalRepoPath localPath = ChooseSyncedLocalPath( + Options.Repos, + repoName, + MakeFullyQualifyLocalRepoPath(Options.DevDirectory / RelativeDirectoryPath.Create(remoteRepo.FullName))); GitRepository? repo = GitRepository.Create(GitRemotePath.Create(remoteRepo.CloneUrl), localPath); if (repo is not null) { @@ -1046,12 +1049,74 @@ private void UpdateClonedStatus() changed |= UpdateClonedStatus(repo); } + changed |= PruneStaleClonedRepos(Options.ClonedRepos, Options.Repos); + if (changed) { QueueSaveOptions(); } } + /// + /// Chooses where a repository listed by a GitHub owner scan lives on disk. + /// + /// The repositories already known. + /// The repository the scan listed. + /// Where the scan would put it: <dev>/<owner>/<repo>. + /// + /// The path the repository is already cloned at, if it is known and cloned, otherwise + /// . + /// + /// + /// Scan Dev Dir records a clone wherever it found it, often directly under the dev directory, and + /// adds the clone's owner to the owners it scans. An owner scan that then replaced the path with + /// the conventional one pointed the repository at a folder that does not exist, so every fetch of + /// it failed and its git actions acted on nothing. + /// + internal static FullyQualifiedLocalRepoPath ChooseSyncedLocalPath( + IReadOnlyDictionary repos, + FullyQualifiedGitHubRepoName repoName, + FullyQualifiedLocalRepoPath conventionalPath) + { + Ensure.NotNull(repos); + + return repos.TryGetValue(repoName, out GitRepository? existing) + && !string.IsNullOrEmpty(existing.LocalPath) + && GitCli.IsRepository(existing.LocalPath) + ? existing.LocalPath + : conventionalPath; + } + + /// + /// Removes the clones recorded at a path their repository no longer points at. + /// + /// The recorded clones, keyed by path. + /// The repositories they belong to. + /// if any entry was removed. + /// + /// Cloned status is otherwise updated only by walking at each + /// repository's current path, so an entry left at an old path is never visited and outlives the + /// change that moved its repository. + /// + internal static bool PruneStaleClonedRepos( + Dictionary clonedRepos, + IReadOnlyDictionary repos) + { + Ensure.NotNull(clonedRepos); + Ensure.NotNull(repos); + + List stale = [.. clonedRepos + .Where(entry => !repos.TryGetValue(entry.Value, out GitRepository? repo) || repo.LocalPath != entry.Key) + .Select(entry => entry.Key)]; + + foreach (FullyQualifiedLocalRepoPath path in stale) + { + _ = clonedRepos.Remove(path); + } + + return stale.Count > 0; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0045:Convert to conditional expression", Justification = "")] private bool UpdateClonedStatus(GitRepository repo) {