From 6c7ff3bce6c2cd8022eb5bf35c3a5afcc7219322 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:28:26 +0000 Subject: [PATCH] Match 401/403 only in git's own phrasing when classifying a refused probe [patch] AdmissionGate matched bare "401" and "403" anywhere in git's output, and git echoes the repository URL, so a DNS or proxy failure for a repository whose URL contains those digits was reported as a refused credential, as was Azure DevOps's TF401019 "repository does not exist". The markers are now "returned error: 40x" and "HTTP 40x". Fixes ktsu-dev/GitBranchStateCache#47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JE3qjjjTXN28xUcTq2h6vD --- .../Admission/AdmissionGateTests.cs | 39 +++++++++++++++++++ .../Admission/AdmissionGate.cs | 12 +++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/GitBranchStateCache.Tests/Admission/AdmissionGateTests.cs b/GitBranchStateCache.Tests/Admission/AdmissionGateTests.cs index 52d1a81..3b4fa40 100644 --- a/GitBranchStateCache.Tests/Admission/AdmissionGateTests.cs +++ b/GitBranchStateCache.Tests/Admission/AdmissionGateTests.cs @@ -102,6 +102,45 @@ public async Task AdmitAsync_WhenTheForgeIsUnreachable_StillRefuses() Assert.AreEqual(502, outcome.StatusCode); } + [TestMethod] + [DataRow("fatal: unable to access 'https://nonexistent-host.invalid/studio/game-401.git/': CONNECT tunnel failed, response 502")] + [DataRow("fatal: unable to access 'https://github.com/studio/game-403.git/': Could not resolve host: github.com")] + [DataRow("fatal: unable to access 'https://forge.example:4401/studio/game.git/': Failed to connect to forge.example port 4401")] + public async Task AdmitAsync_WhenTheForgeIsUnreachableAtAUrlContainingAStatusCode_Is502(string error) + { + // Git echoes the repository URL, so digits in it must not be read as the status the forge sent. + (AdmissionGate gate, FakeGitRunner runner, _) = Build(); + runner.Respond = _ => new GitResult(128, string.Empty, error, TimedOut: false); + + Assert.AreEqual(502, (await AdmitAsync(gate)).StatusCode); + } + + [TestMethod] + [DataRow("fatal: unable to access 'https://github.com/studio/game.git/': The requested URL returned error: 401")] + [DataRow("remote: Permission to studio/game.git denied to someone.\nfatal: unable to access 'https://github.com/studio/game.git/': The requested URL returned error: 403")] + [DataRow("error: RPC failed; HTTP 403 curl 22 The requested URL returned error: 403")] + public async Task AdmitAsync_WhenGitReportsA401Or403_Is401(string error) + { + (AdmissionGate gate, FakeGitRunner runner, _) = Build(); + runner.Respond = _ => new GitResult(128, string.Empty, error, TimedOut: false); + + Assert.AreEqual(401, (await AdmitAsync(gate)).StatusCode); + } + + [TestMethod] + public async Task AdmitAsync_WhenAzureDevOpsReportsTF401019_Is404() + { + // TF401019 is Azure DevOps's "repository does not exist", whatever its code looks like. + (AdmissionGate gate, FakeGitRunner runner, _) = Build(); + runner.Respond = _ => new GitResult( + 128, + string.Empty, + "remote: TF401019: The Git repository with name or identifier game does not exist or you do not have permissions for the operation you are attempting.", + TimedOut: false); + + Assert.AreEqual(404, (await AdmitAsync(gate)).StatusCode); + } + [TestMethod] public async Task AdmitAsync_WhenTheProbeTimesOut_Refuses() { diff --git a/GitBranchStateCache/Admission/AdmissionGate.cs b/GitBranchStateCache/Admission/AdmissionGate.cs index aefdae3..f6cefdb 100644 --- a/GitBranchStateCache/Admission/AdmissionGate.cs +++ b/GitBranchStateCache/Admission/AdmissionGate.cs @@ -40,6 +40,12 @@ public sealed class AdmissionGate( /// Matched to choose a status code and nothing else. Every branch of this refuses; the only /// question is whether the caller is told to fix their credential or that the forge could not be /// reached, and getting that wrong costs a confusing message rather than an incorrect decision. + /// + /// A status code is only matched in git's own phrasing, never as bare digits. Git echoes the + /// repository URL in almost every failure, so a bare "401" would read a DNS or proxy failure + /// for studio/game-401.git as a refused credential, and Azure DevOps's + /// TF401019 "repository does not exist" as one too. + /// /// private static readonly string[] AuthenticationMarkers = [ @@ -48,8 +54,10 @@ public sealed class AdmissionGate( "could not read password", "invalid username or password", "http basic: access denied", - "403", - "401", + "returned error: 401", + "returned error: 403", + "http 401", + "http 403", ]; ///