diff --git a/ProjectDirector.Test/GitHubRemoteTests.cs b/ProjectDirector.Test/GitHubRemoteTests.cs
new file mode 100644
index 0000000..240ebde
--- /dev/null
+++ b/ProjectDirector.Test/GitHubRemoteTests.cs
@@ -0,0 +1,75 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.ProjectDirector.Test;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests the parsing of a GitHub remote into its owner and repository name.
+///
+///
+/// "Scan Dev Dir" keeps a working tree only when its origin parses here, so a form left out drops
+/// every repository cloned that way without a word. SSH clones were exactly that case.
+///
+[TestClass]
+public sealed class GitHubRemoteTests
+{
+ [TestMethod]
+ [DataRow("https://github.com/ktsu-dev/ProjectDirector")]
+ [DataRow("https://github.com/ktsu-dev/ProjectDirector.git")]
+ [DataRow("https://github.com/ktsu-dev/ProjectDirector/")]
+ [DataRow("https://github.com/ktsu-dev/ProjectDirector.git/")]
+ [DataRow("HTTPS://GitHub.com/ktsu-dev/ProjectDirector.git")]
+ [DataRow("git@github.com:ktsu-dev/ProjectDirector")]
+ [DataRow("git@github.com:ktsu-dev/ProjectDirector.git")]
+ [DataRow("git@github.com:ktsu-dev/ProjectDirector/")]
+ [DataRow("git@GitHub.com:ktsu-dev/ProjectDirector.git")]
+ [DataRow("ssh://git@github.com/ktsu-dev/ProjectDirector")]
+ [DataRow("ssh://git@github.com/ktsu-dev/ProjectDirector.git")]
+ [DataRow("ssh://git@github.com/ktsu-dev/ProjectDirector.git/")]
+ [DataRow("SSH://git@GITHUB.COM/ktsu-dev/ProjectDirector.git")]
+ public void EveryGitHubRemoteFormYieldsItsOwnerAndRepository(string remote)
+ {
+ // Act
+ bool parsed = GitHubRepository.TryParseRemote(remote, out string owner, out string repo);
+
+ // Assert
+ Assert.IsTrue(parsed, $"{remote} should be recognised as a GitHub remote.");
+ Assert.AreEqual("ktsu-dev", owner);
+ Assert.AreEqual("ProjectDirector", repo);
+ Assert.IsTrue(GitHubRepository.IsRemotePathValid(GitRemotePath.Create(remote)));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("https://gitlab.com/ktsu-dev/ProjectDirector.git")]
+ [DataRow("https://github.com.evil.example/ktsu-dev/ProjectDirector.git")]
+ [DataRow("git@bitbucket.org:ktsu-dev/ProjectDirector.git")]
+ [DataRow("https://github.com/ktsu-dev")]
+ [DataRow("https://github.com/ktsu-dev/")]
+ [DataRow("https://github.com//ProjectDirector")]
+ [DataRow("https://github.com/ktsu-dev/ProjectDirector/tree/main")]
+ [DataRow("C:/dev/ProjectDirector")]
+ public void AnythingElseIsNotAGitHubRemote(string remote)
+ {
+ // Act
+ bool parsed = GitHubRepository.TryParseRemote(remote, out string owner, out string repo);
+
+ // Assert
+ Assert.IsFalse(parsed, $"{remote} should not be recognised as a GitHub remote.");
+ Assert.AreEqual(string.Empty, owner);
+ Assert.AreEqual(string.Empty, repo);
+ }
+
+ [TestMethod]
+ public void AnSshCloneCreatesAGitHubRepository()
+ {
+ // Act
+ GitRepository? repo = GitRepository.Create(
+ GitRemotePath.Create("git@github.com:ktsu-dev/ProjectDirector.git"),
+ FullyQualifiedLocalRepoPath.Create("/dev/ktsu-dev/ProjectDirector"));
+
+ // Assert
+ Assert.IsInstanceOfType(repo);
+ }
+}
diff --git a/ProjectDirector/GitHubRepository.cs b/ProjectDirector/GitHubRepository.cs
index 1a77a11..3a2b82f 100644
--- a/ProjectDirector/GitHubRepository.cs
+++ b/ProjectDirector/GitHubRepository.cs
@@ -17,9 +17,68 @@ public sealed class GitHubRepository : GitRepository
public GitHubOwnerName OwnerName { get; set; } = new();
public GitHubRepoName RepoName { get; set; } = new();
+ private static readonly string[] RemotePrefixes = ["https://github.com/", "ssh://git@github.com/", "git@github.com:"];
+
internal static bool IsRemotePathValid(GitRemotePath remotePath)
{
Ensure.NotNull(remotePath);
- return remotePath.StartsWith("https://github.com/", StringComparison.Ordinal);
+ return TryParseRemote(remotePath, out _, out _);
+ }
+
+ ///
+ /// Reads the owner and repository name out of a GitHub remote.
+ ///
+ /// The remote URL, as git remote get-url reports it.
+ /// The owner, empty when the remote is not a GitHub remote.
+ /// The repository name without any .git suffix, empty when the remote is not a GitHub remote.
+ /// Whether names a repository on GitHub.
+ ///
+ /// Accepts the three forms git itself clones from: https://github.com/owner/repo,
+ /// the scp-style git@github.com:owner/repo and ssh://git@github.com/owner/repo,
+ /// each with or without a .git suffix and a trailing /. The host is compared
+ /// ignoring case, as DNS does, and the owner and repository name are kept as given. Git's
+ /// credential helper and SSH agent are what make the SSH forms work, so turning them away here
+ /// would drop every repository cloned over SSH.
+ ///
+ internal static bool TryParseRemote(string remote, out string owner, out string repo)
+ {
+ owner = string.Empty;
+ repo = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(remote))
+ {
+ return false;
+ }
+
+ string? path = null;
+ foreach (string prefix in RemotePrefixes)
+ {
+ if (remote.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ path = remote[prefix.Length..];
+ break;
+ }
+ }
+
+ if (path is null)
+ {
+ return false;
+ }
+
+ path = path.TrimEnd('/');
+ if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
+ {
+ path = path[..^".git".Length];
+ }
+
+ string[] parts = path.Split('/');
+ if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0)
+ {
+ return false;
+ }
+
+ owner = parts[0];
+ repo = parts[1];
+ return true;
}
}
diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs
index 2702c42..ab0116b 100644
--- a/ProjectDirector/ProjectDirector.cs
+++ b/ProjectDirector/ProjectDirector.cs
@@ -1171,27 +1171,24 @@ private void ScanDevDirectoryForOwnersAndRepos()
FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(AbsoluteDirectoryPath.Create(workingDirectory));
GitRemotePath remoteUrl = GitRemotePath.Create(originUrl);
+ if (!GitHubRepository.TryParseRemote(originUrl, out string owner, out string name))
+ {
+ QueueLog($"[{DateTimeOffset.Now}] Skipped {workingDirectory}: origin {originUrl} is not a GitHub repository");
+ continue;
+ }
+
try
{
GitRepository? repo = GitRepository.Create(remoteUrl, localPath);
if (repo is GitHubRepository gitHubRepo)
{
- List remoteUrlParts = [];
- foreach (ReadOnlySpan part in remoteUrl.Split('/'))
- {
- remoteUrlParts.Add(part.ToString());
- }
- remoteUrlParts.Reverse();
- if (remoteUrlParts.Count >= 2)
- {
- GitHubRepoName repoName = GitHubRepoName.Create(remoteUrlParts[0].RemoveSuffix(".git"));
- GitHubOwnerName ownerName = GitHubOwnerName.Create(remoteUrlParts[1]);
- FullyQualifiedGitHubRepoName repoFullName = GetFullyQualifiedRepoName(ownerName, repoName);
- Options.Repos[repoFullName] = gitHubRepo;
- gitHubRepo.OwnerName = ownerName;
- gitHubRepo.RepoName = repoName;
- _ = Options.GitHubOwners.Add(gitHubRepo.OwnerName);
- }
+ GitHubRepoName repoName = GitHubRepoName.Create(name);
+ GitHubOwnerName ownerName = GitHubOwnerName.Create(owner);
+ FullyQualifiedGitHubRepoName repoFullName = GetFullyQualifiedRepoName(ownerName, repoName);
+ Options.Repos[repoFullName] = gitHubRepo;
+ gitHubRepo.OwnerName = ownerName;
+ gitHubRepo.RepoName = repoName;
+ _ = Options.GitHubOwners.Add(gitHubRepo.OwnerName);
}
}
catch (NotSupportedException)