Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions ProjectDirector.Test/OwnerSyncTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Covers an owner scan meeting a repository that Scan Dev Dir already found cloned elsewhere.
/// </summary>
/// <remarks>
/// 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
/// <c>&lt;dev&gt;/&lt;owner&gt;/&lt;repo&gt;</c> 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.
/// </remarks>
[TestClass]
public sealed class OwnerSyncTests
{
private static FullyQualifiedGitHubRepoName Name(string repoName) =>
FullyQualifiedGitHubRepoName.Create<FullyQualifiedGitHubRepoName>($"ktsu-dev.{repoName}");

private static FullyQualifiedLocalRepoPath LocalPath(string path) =>
FullyQualifiedLocalRepoPath.Create<FullyQualifiedLocalRepoPath>(path);

private static GitHubRepository Repository(string localPath, string repoName) => new()
{
OwnerName = GitHubOwnerName.Create<GitHubOwnerName>("ktsu-dev"),
RepoName = GitHubRepoName.Create<GitHubRepoName>(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<FullyQualifiedGitHubRepoName, GitRepository> 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<FullyQualifiedGitHubRepoName, GitRepository>(), Name("New"), conventional);

Assert.AreEqual(conventional, chosen);
}

[TestMethod]
public void AnOwnerScanUsesTheConventionalFolderWhenTheKnownOneIsNotAClone()
{
string missing = Path.Join(Path.GetTempPath(), $"ktsu_pd_{Guid.NewGuid():N}", "Gone");
Dictionary<FullyQualifiedGitHubRepoName, GitRepository> 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<FullyQualifiedGitHubRepoName, GitRepository> repos = new()
{
[Name("ProjectDirector")] = Repository(newPath, "ProjectDirector"),
};
Dictionary<FullyQualifiedLocalRepoPath, FullyQualifiedGitHubRepoName> cloned = new()
{
[LocalPath(oldPath)] = Name("ProjectDirector"),
};

Assert.IsTrue(ProjectDirector.PruneStaleClonedRepos(cloned, repos));
Assert.IsEmpty(cloned);
}

[TestMethod]
public void ACloneOfAnUnknownRepositoryIsPruned()
{
Dictionary<FullyQualifiedLocalRepoPath, FullyQualifiedGitHubRepoName> cloned = new()
{
[LocalPath(Path.Join(Path.GetTempPath(), "dev", "Orphan"))] = Name("Orphan"),
};

Assert.IsTrue(ProjectDirector.PruneStaleClonedRepos(cloned, new Dictionary<FullyQualifiedGitHubRepoName, GitRepository>()));
Assert.IsEmpty(cloned);
}

[TestMethod]
public void ACloneRecordedAtItsRepositorysPathIsKept()
{
string path = Path.Join(Path.GetTempPath(), "dev", "ProjectDirector");
Dictionary<FullyQualifiedGitHubRepoName, GitRepository> repos = new()
{
[Name("ProjectDirector")] = Repository(path, "ProjectDirector"),
};
Dictionary<FullyQualifiedLocalRepoPath, FullyQualifiedGitHubRepoName> 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.
}
}
}
67 changes: 66 additions & 1 deletion ProjectDirector/ProjectDirector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
using ktsu.ImGui.Widgets;
using ktsu.ImGui.Styler;
using Octokit;
// using OpenAI.Chat;

Check warning on line 21 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.

Check warning on line 21 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.
using Semantics.Paths;

#pragma warning disable CA1506
Expand Down Expand Up @@ -56,7 +56,7 @@
/// </summary>
private GitHubOwnerName? OwnerPendingTokenPopup { get; set; }

// private ChatClient ChatClient { get; init; }

Check warning on line 59 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.

Check warning on line 59 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.

private static void Main(string[] _)
{
Expand All @@ -79,7 +79,7 @@
_ = MakeLoadedOptionsSafe(Options, QueueLog);

Options.Save();
// ChatClient = new(model: "gpt-4o", new ApiKeyCredential(Options.OpenAIToken));

Check warning on line 82 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.

Check warning on line 82 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.
DividerDiff = new("DiffDivider", DividerResized, ImGuiWidgets.DividerLayout.Columns);
DividerContainerCols = new("VerticalDivider", DividerResized, ImGuiWidgets.DividerLayout.Columns);
DividerContainerRows = new("HorizontalDivider", DividerResized, ImGuiWidgets.DividerLayout.Rows);
Expand Down Expand Up @@ -738,7 +738,7 @@
});
}

//int fetchInterval = repo.MinFetchIntervalSeconds;

Check warning on line 741 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.

Check warning on line 741 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.
//if (ImGuiWidgets.Knob("Min Fetch Interval", ref fetchInterval, 0, 300, 150))
//{
// repo.MinFetchIntervalSeconds = fetchInterval;
Expand Down Expand Up @@ -1012,8 +1012,11 @@

foreach (Repository remoteRepo in remoteRepos)
{
FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(Options.DevDirectory / RelativeDirectoryPath.Create<RelativeDirectoryPath>(remoteRepo.FullName));
FullyQualifiedGitHubRepoName repoName = GetFullyQualifiedRepoName(remoteRepo);
FullyQualifiedLocalRepoPath localPath = ChooseSyncedLocalPath(
Options.Repos,
repoName,
MakeFullyQualifyLocalRepoPath(Options.DevDirectory / RelativeDirectoryPath.Create<RelativeDirectoryPath>(remoteRepo.FullName)));
GitRepository? repo = GitRepository.Create(GitRemotePath.Create<GitRemotePath>(remoteRepo.CloneUrl), localPath);
if (repo is not null)
{
Expand All @@ -1038,7 +1041,7 @@
}
}

private void UpdateClonedStatus()

Check warning on line 1044 in ProjectDirector/ProjectDirector.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

All 'UpdateClonedStatus' method overloads should be adjacent.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ProjectDirector&issues=AaESSYK2UBFYDTZdxoGX&open=AaESSYK2UBFYDTZdxoGX&pullRequest=473
{
bool changed = false;
foreach ((FullyQualifiedGitHubRepoName _, GitRepository repo) in Options.Repos)
Expand All @@ -1046,12 +1049,74 @@
changed |= UpdateClonedStatus(repo);
}

changed |= PruneStaleClonedRepos(Options.ClonedRepos, Options.Repos);

if (changed)
{
QueueSaveOptions();
}
}

/// <summary>
/// Chooses where a repository listed by a GitHub owner scan lives on disk.
/// </summary>
/// <param name="repos">The repositories already known.</param>
/// <param name="repoName">The repository the scan listed.</param>
/// <param name="conventionalPath">Where the scan would put it: <c>&lt;dev&gt;/&lt;owner&gt;/&lt;repo&gt;</c>.</param>
/// <returns>
/// The path the repository is already cloned at, if it is known and cloned, otherwise
/// <paramref name="conventionalPath"/>.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
internal static FullyQualifiedLocalRepoPath ChooseSyncedLocalPath(
IReadOnlyDictionary<FullyQualifiedGitHubRepoName, GitRepository> 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;
}

/// <summary>
/// Removes the clones recorded at a path their repository no longer points at.
/// </summary>
/// <param name="clonedRepos">The recorded clones, keyed by path.</param>
/// <param name="repos">The repositories they belong to.</param>
/// <returns><see langword="true"/> if any entry was removed.</returns>
/// <remarks>
/// Cloned status is otherwise updated only by walking <paramref name="repos"/> 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.
/// </remarks>
internal static bool PruneStaleClonedRepos(
Dictionary<FullyQualifiedLocalRepoPath, FullyQualifiedGitHubRepoName> clonedRepos,
IReadOnlyDictionary<FullyQualifiedGitHubRepoName, GitRepository> repos)
{
Ensure.NotNull(clonedRepos);
Ensure.NotNull(repos);

List<FullyQualifiedLocalRepoPath> 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 = "<Pending>")]
private bool UpdateClonedStatus(GitRepository repo)
{
Expand Down Expand Up @@ -2170,7 +2235,7 @@

if (ImGui.TableNextColumn())
{
//if (ImGui.Button($"Propagate Directory###Propagate{path.Replace(Path.DirectorySeparatorChar, '.').Replace(Path.AltDirectorySeparatorChar, '.')}"))

Check warning on line 2238 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.
//{
// shouldOpenPopup |= true;
// Options.PropagatePath = path;
Expand All @@ -2179,7 +2244,7 @@

if (ImGui.TableNextColumn())
{
//if (ImGui.Button($"X"))

Check warning on line 2247 in ProjectDirector/ProjectDirector.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this commented out code.
//{
// //Directory.Delete(Path.Combine(Options.Repos[Options.BaseRepo].LocalPath, Options.BrowsePath, path));
//}
Expand Down
Loading