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",
];
///