diff --git a/ProjectDirector.Test/UnsupportedRepoTests.cs b/ProjectDirector.Test/UnsupportedRepoTests.cs
new file mode 100644
index 0000000..64d8db7
--- /dev/null
+++ b/ProjectDirector.Test/UnsupportedRepoTests.cs
@@ -0,0 +1,248 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.ProjectDirector.Test;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests the rule that refuses a saved repository this application cannot act on.
+///
+///
+/// registers as a derived type for
+/// polymorphic JSON, so a saved options file carrying one deserializes without complaint. Nothing
+/// then acts on it: every site that pattern-matches a repository handles
+/// and throws otherwise. UpdateClonedStatus runs from the
+/// constructor's RefreshPage, so that throw landed on the next launch, before the user could
+/// open the UI to delete the entry causing it -- unrecoverable without hand-editing the file.
+///
+/// RejectUnsupportedRepos and MakeLoadedOptionsSafe exist so the one boundary such an
+/// entry can arrive through can be driven without a live ImGui context, the way
+/// drives the pull rule. What it guarantees is the invariant those six throw sites rest on: after it
+/// runs, every repository left in the options is a .
+///
+[TestClass]
+public sealed class UnsupportedRepoTests
+{
+ private static FullyQualifiedGitHubRepoName RepoName(string value) => FullyQualifiedGitHubRepoName.Create(value);
+ private static FullyQualifiedLocalRepoPath LocalPath(string value) => FullyQualifiedLocalRepoPath.Create(value);
+
+ ///
+ /// The premise: an Azure DevOps repository really does come back from JSON as a live object,
+ /// rather than being rejected by the serializer.
+ ///
+ ///
+ /// The discriminator alone is enough, which is the point -- this is what a hand-edited options
+ /// file, or one left over from a partially built feature, looks like.
+ ///
+ [TestMethod]
+ public void AnAzureDevOpsRepositoryDeserializesIntoALiveObject()
+ {
+ GitRepository? fromSavedOptions = JsonSerializer.Deserialize("""{"TypeName":"AzureDevOpsRepository"}""");
+
+ Assert.IsInstanceOfType(fromSavedOptions,
+ "If this stops holding, the crash this rule guards against is no longer reachable and the rule can go.");
+ }
+
+ ///
+ /// The regression this file exists for.
+ ///
+ [TestMethod]
+ public void AnUnsupportedRepoIsDroppedAndTheSupportedOnesAreKept()
+ {
+ Dictionary repos = new()
+ {
+ [RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(),
+ [RepoName("contoso.internal")] = new AzureDevOpsRepository(),
+ };
+ Dictionary clonedRepos = [];
+
+ IReadOnlyList rejected = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos);
+
+ CollectionAssert.AreEqual(new[] { RepoName("contoso.internal") }, rejected.ToArray());
+ CollectionAssert.AreEqual(
+ new[] { RepoName("ktsu-dev.ProjectDirector") },
+ repos.Keys.ToArray(),
+ "The supported repositories must survive.");
+
+ Assert.IsTrue(repos.Values.All(repo => repo is GitHubRepository),
+ "Every site that pattern-matches a repository rests on this invariant.");
+ }
+
+ [TestMethod]
+ public void NothingUnsupportedMeansNothingIsTouched()
+ {
+ Dictionary repos = new()
+ {
+ [RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(),
+ [RepoName("ktsu-dev.ImGuiApp")] = new GitHubRepository(),
+ };
+ Dictionary clonedRepos = new()
+ {
+ [LocalPath("/dev/ProjectDirector")] = RepoName("ktsu-dev.ProjectDirector"),
+ };
+
+ IReadOnlyList rejected = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos);
+
+ Assert.AreEqual(0, rejected.Count);
+ Assert.AreEqual(2, repos.Count);
+ Assert.AreEqual(1, clonedRepos.Count);
+ Assert.AreEqual(
+ RepoName("ktsu-dev.ProjectDirector"),
+ ProjectDirector.ClearSelectionIfRejected(RepoName("ktsu-dev.ProjectDirector"), rejected),
+ "An ordinary selection must not be disturbed.");
+ }
+
+ [TestMethod]
+ public void ACloneRecordedAgainstADroppedRepoIsCleared()
+ {
+ Dictionary repos = new()
+ {
+ [RepoName("contoso.internal")] = new AzureDevOpsRepository(),
+ [RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository(),
+ };
+ Dictionary clonedRepos = new()
+ {
+ [LocalPath("/dev/internal")] = RepoName("contoso.internal"),
+ [LocalPath("/dev/ProjectDirector")] = RepoName("ktsu-dev.ProjectDirector"),
+ };
+
+ _ = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos);
+
+ CollectionAssert.AreEqual(
+ new[] { LocalPath("/dev/ProjectDirector") },
+ clonedRepos.Keys.ToArray(),
+ "A clone must not keep naming a repository that is no longer in the options.");
+ }
+
+ [TestMethod]
+ public void EveryRepoBeingUnsupportedLeavesAnEmptyButUsableState()
+ {
+ Dictionary repos = new()
+ {
+ [RepoName("contoso.internal")] = new AzureDevOpsRepository(),
+ [RepoName("contoso.other")] = new AzureDevOpsRepository(),
+ };
+ Dictionary clonedRepos = new()
+ {
+ [LocalPath("/dev/internal")] = RepoName("contoso.internal"),
+ };
+
+ IReadOnlyList rejected = ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos);
+
+ Assert.AreEqual(2, rejected.Count);
+ Assert.AreEqual(0, repos.Count);
+ Assert.AreEqual(0, clonedRepos.Count);
+ }
+
+ ///
+ /// The whole of what the constructor does after loading, against real options: the crashing
+ /// entry goes, the selection pointing at it goes with it, the supported repository stays, and
+ /// the user is told why.
+ ///
+ [TestMethod]
+ public void LoadedOptionsCarryingAnUnsupportedRepoAreMadeSafe()
+ {
+ using ProjectDirectorOptions options = new()
+ {
+ BaseRepo = RepoName("contoso.internal"),
+ CompareRepo = RepoName("ktsu-dev.ProjectDirector"),
+ };
+ options.Repos[RepoName("contoso.internal")] = new AzureDevOpsRepository();
+ options.Repos[RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository();
+ options.ClonedRepos[LocalPath("/dev/internal")] = RepoName("contoso.internal");
+
+ List logged = [];
+ IReadOnlyList rejected = ProjectDirector.MakeLoadedOptionsSafe(options, logged.Add);
+
+ CollectionAssert.AreEqual(new[] { RepoName("contoso.internal") }, rejected.ToArray());
+ CollectionAssert.AreEqual(new[] { RepoName("ktsu-dev.ProjectDirector") }, options.Repos.Keys.ToArray());
+ Assert.AreEqual(0, options.ClonedRepos.Count);
+
+ Assert.AreEqual(new FullyQualifiedGitHubRepoName(), options.BaseRepo, "The base selection named the rejected repository.");
+ Assert.AreEqual(RepoName("ktsu-dev.ProjectDirector"), options.CompareRepo, "The compare selection named a surviving one and must be left alone.");
+
+ Assert.AreEqual(1, logged.Count, "Each rejection should be reported once.");
+ StringAssert.Contains(logged[0], "contoso.internal", StringComparison.Ordinal);
+ }
+
+ [TestMethod]
+ public void LoadedOptionsWithNothingUnsupportedAreLeftAlone()
+ {
+ using ProjectDirectorOptions options = new()
+ {
+ BaseRepo = RepoName("ktsu-dev.ProjectDirector"),
+ };
+ options.Repos[RepoName("ktsu-dev.ProjectDirector")] = new GitHubRepository();
+
+ List logged = [];
+ IReadOnlyList rejected = ProjectDirector.MakeLoadedOptionsSafe(options, logged.Add);
+
+ Assert.AreEqual(0, rejected.Count);
+ Assert.AreEqual(1, options.Repos.Count);
+ Assert.AreEqual(RepoName("ktsu-dev.ProjectDirector"), options.BaseRepo);
+ Assert.AreEqual(0, logged.Count, "Nothing to reject means nothing to report.");
+ }
+
+ ///
+ /// Fresh options have to be constructible on every platform this repository tests on, which is
+ /// what a hardcoded C:\dev default prevented.
+ ///
+ [TestMethod]
+ public void FreshOptionsCanBeConstructed()
+ {
+ using ProjectDirectorOptions options = new();
+
+ Assert.IsFalse(string.IsNullOrEmpty(options.DevDirectory), "A fresh install needs a usable dev directory default.");
+ }
+
+ [TestMethod]
+ public void EmptyOptionsAreHandled()
+ {
+ Dictionary repos = [];
+ Dictionary clonedRepos = [];
+
+ Assert.AreEqual(0, ProjectDirector.RejectUnsupportedRepos(repos, clonedRepos).Count);
+ Assert.AreEqual(0, repos.Count);
+ }
+
+ ///
+ /// A selection left pointing at a dropped repository would send the panels straight back into
+ /// Options.Repos[Options.BaseRepo], turning one crash into another.
+ ///
+ [TestMethod]
+ public void ASelectionPointingAtADroppedRepoIsCleared()
+ {
+ IReadOnlyList rejected = [RepoName("contoso.internal")];
+
+ Assert.AreEqual(
+ new FullyQualifiedGitHubRepoName(),
+ ProjectDirector.ClearSelectionIfRejected(RepoName("contoso.internal"), rejected),
+ "The selection must not outlive the repository it names.");
+ }
+
+ [TestMethod]
+ public void ASelectionNamingASurvivingRepoIsKept()
+ {
+ IReadOnlyList rejected = [RepoName("contoso.internal")];
+
+ Assert.AreEqual(
+ RepoName("ktsu-dev.ProjectDirector"),
+ ProjectDirector.ClearSelectionIfRejected(RepoName("ktsu-dev.ProjectDirector"), rejected));
+ }
+
+ [TestMethod]
+ public void AnEmptySelectionSurvivesAnEmptyRejectionList()
+ {
+ IReadOnlyList rejected = [];
+
+ Assert.AreEqual(
+ new FullyQualifiedGitHubRepoName(),
+ ProjectDirector.ClearSelectionIfRejected(new FullyQualifiedGitHubRepoName(), rejected),
+ "A fresh install selects nothing, and that must not be mistaken for a rejection.");
+ }
+}
diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs
index 79d2af9..c112877 100644
--- a/ProjectDirector/ProjectDirector.cs
+++ b/ProjectDirector/ProjectDirector.cs
@@ -62,6 +62,9 @@ private static void Main(string[] _)
public ProjectDirector()
{
Options = ProjectDirectorOptions.LoadOrCreate();
+
+ _ = MakeLoadedOptionsSafe(Options, QueueLog);
+
Options.Save();
// ChatClient = new(model: "gpt-4o", new ApiKeyCredential(Options.OpenAIToken));
DividerDiff = new("DiffDivider", DividerResized, ImGuiWidgets.DividerLayout.Columns);
@@ -98,6 +101,106 @@ public ProjectDirector()
RefreshPage();
}
+ ///
+ /// Drops any saved repository this application cannot act on, along with any selection left
+ /// pointing at one.
+ ///
+ /// The freshly loaded repositories, modified in place.
+ /// The freshly loaded clone records, modified in place.
+ /// The names of the repositories that were dropped, in the order they were found.
+ ///
+ /// registers as a
+ /// , so a saved options
+ /// file carrying one deserializes without complaint. Nothing acts on it: every site that
+ /// pattern-matches a repository handles and throws otherwise,
+ /// and cannot produce anything else in the first place.
+ /// UpdateClonedStatus then runs from the constructor's RefreshPage, so the throw
+ /// landed on the very next launch -- before the user could open the UI and delete the entry
+ /// that was causing it, which made it unrecoverable without hand-editing the file.
+ /// Rejecting the entry at the one boundary it can arrive through is what makes that
+ /// unreachable, rather than guarding six call sites separately. The registration is left in
+ /// place so an existing file still parses; it is the live object that is refused.
+ /// The collections are taken rather than the whole so this
+ /// rule can be driven without constructing one.
+ ///
+ internal static IReadOnlyList RejectUnsupportedRepos(
+ IDictionary repos,
+ IDictionary clonedRepos)
+ {
+ Ensure.NotNull(repos);
+ Ensure.NotNull(clonedRepos);
+
+ List rejected = [.. repos
+ .Where(kvp => kvp.Value is not GitHubRepository)
+ .Select(kvp => kvp.Key)];
+
+ foreach (FullyQualifiedGitHubRepoName name in rejected)
+ {
+ _ = repos.Remove(name);
+ }
+
+ // A clone recorded against a rejected repository would otherwise keep naming it.
+ foreach (FullyQualifiedLocalRepoPath path in clonedRepos
+ .Where(kvp => rejected.Contains(kvp.Value))
+ .Select(kvp => kvp.Key)
+ .ToList())
+ {
+ _ = clonedRepos.Remove(path);
+ }
+
+ return rejected;
+ }
+
+ ///
+ /// Clears a selected repository name that names one of the
+ /// repositories.
+ ///
+ /// The saved selection.
+ /// The repositories that were dropped.
+ /// The selection, or an empty name where it named a dropped repository.
+ ///
+ /// Once something is selected the panels reach for it through
+ /// Options.Repos[Options.BaseRepo], so a selection outliving its repository turns one
+ /// crash into another. An empty name is the state a fresh install starts in, which the
+ /// surrounding TryGetValue checks already handle.
+ ///
+ internal static FullyQualifiedGitHubRepoName ClearSelectionIfRejected(
+ FullyQualifiedGitHubRepoName selection,
+ IReadOnlyList rejected)
+ {
+ Ensure.NotNull(rejected);
+
+ return rejected.Contains(selection) ? new() : selection;
+ }
+
+ ///
+ /// Rejects every saved repository this application cannot act on, clears anything left pointing
+ /// at one, and reports each rejection.
+ ///
+ /// The freshly loaded options, modified in place.
+ /// Where to report each rejected repository.
+ /// The names of the repositories that were dropped.
+ ///
+ /// The whole of what the constructor does after loading, in one place, so it can be driven
+ /// without an ImGui context.
+ ///
+ internal static IReadOnlyList MakeLoadedOptionsSafe(ProjectDirectorOptions options, Action log)
+ {
+ Ensure.NotNull(options);
+ Ensure.NotNull(log);
+
+ IReadOnlyList rejected = RejectUnsupportedRepos(options.Repos, options.ClonedRepos);
+ options.BaseRepo = ClearSelectionIfRejected(options.BaseRepo, rejected);
+ options.CompareRepo = ClearSelectionIfRejected(options.CompareRepo, rejected);
+
+ foreach (FullyQualifiedGitHubRepoName name in rejected)
+ {
+ log($"Ignoring saved repository '{name}': only GitHub repositories are supported at this time.");
+ }
+
+ return rejected;
+ }
+
private void QueueLog(string logMessage)
{
LogQueue.Enqueue(logMessage);
diff --git a/ProjectDirector/ProjectDirectorOptions.cs b/ProjectDirector/ProjectDirectorOptions.cs
index b624423..7a11160 100644
--- a/ProjectDirector/ProjectDirectorOptions.cs
+++ b/ProjectDirector/ProjectDirectorOptions.cs
@@ -18,7 +18,22 @@ public sealed record class FullyQualifiedLocalRepoPath : SemanticString
{
- public AbsoluteDirectoryPath DevDirectory { get; set; } = AbsoluteDirectoryPath.Create(@"C:\dev");
+ public AbsoluteDirectoryPath DevDirectory { get; set; } = DefaultDevDirectory();
+
+ ///
+ /// The dev directory a fresh install starts with.
+ ///
+ ///
+ /// C:\dev is not an absolute path anywhere but Windows, so
+ /// rejected it and this type could not be constructed at all
+ /// off Windows -- not by the application, and not by a test. Windows keeps the path it has
+ /// always had; everywhere else falls back to ~/dev. Only a fresh install reads this, so a
+ /// saved options file keeps whatever the user chose.
+ ///
+ private static AbsoluteDirectoryPath DefaultDevDirectory() =>
+ AbsoluteDirectoryPath.Create(OperatingSystem.IsWindows()
+ ? @"C:\dev"
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "dev"));
public ImGuiAppWindowState WindowState { get; set; } = new();
public GitHubLogin GitHubLogin { get; set; } = new();