From 46e5588cce894f6d399af277753e2f8c68967211 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:29:50 +0000 Subject: [PATCH] Recognise SSH GitHub remotes when scanning the dev directory [patch] IsRemotePathValid accepted only https://github.com/ remotes, so Scan Dev Dir silently dropped every repository cloned over SSH. The scan also derived owner and repo by splitting on '/' and reversing, which a trailing slash broke. GitHubRepository.TryParseRemote now reads owner and repo from the https, scp-style (git@github.com:) and ssh:// forms, with or without .git and a trailing slash, comparing the host ignoring case. Both IsRemotePathValid and the scan use it, and the scan logs each remote it skips. Fixes #441 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CaZMWc5xm4vDRcyMefDXyV --- ProjectDirector.Test/GitHubRemoteTests.cs | 75 +++++++++++++++++++++++ ProjectDirector/GitHubRepository.cs | 61 +++++++++++++++++- ProjectDirector/ProjectDirector.cs | 29 ++++----- 3 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 ProjectDirector.Test/GitHubRemoteTests.cs 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)