From bb485efc4dfecad3ae2be12ad2a972e5b6dd48d9 Mon Sep 17 00:00:00 2001 From: Ali-Al1 <11404482+Ali-Al1@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:44:14 +0000 Subject: [PATCH] fix(server): treat git 2.56 broken-gitfile stderr as non-repository Git 2.56 reports a worktree whose `.git` gitfile points at a missing directory as "gitfile does not point to a valid repository" instead of "not a git repository". isNonRepositoryGitStderr only matched the old phrase, and StorageCleanup.branchChanged and GitManager each kept their own copy of the same check, so with git 2.56 storage cleanup reported such worktrees as `failed` rather than keeping them as changed. Export isNonRepositoryGitStderr, match both phrases, and reuse it in storageCleanup and GitManager so the checks cannot drift again. Co-Authored-By: Claude Code --- apps/server/src/git/GitManager.ts | 3 ++- apps/server/src/storageCleanup.ts | 3 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 22 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 12 +++++++++-- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index e6b4bdcfe4230..52220932043d4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -86,6 +86,7 @@ import { detachStackFrame } from "./detachStackFrame.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { isNonRepositoryGitStderr } from "../vcs/GitVcsDriverCore.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -191,7 +192,7 @@ type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; function isNotGitRepositoryError(error: GitCommandError): boolean { - return error.message.toLowerCase().includes("not a git repository"); + return isNonRepositoryGitStderr(error.message); } interface OpenPrInfo { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a5f9cd7eefa12..13fb7288ee057 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -47,6 +47,7 @@ import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import { isNonRepositoryGitStderr } from "./vcs/GitVcsDriverCore.ts"; import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -618,7 +619,7 @@ const make = Effect.gen(function* () { ); if (result === null) return null; if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; - if (result.stderr.toLowerCase().includes("not a git repository")) return null; + if (isNonRepositoryGitStderr(result.stderr)) return null; return yield* failure( result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index baf1368054d4e..9dd6ed197b920 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -37,6 +37,7 @@ import { import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; import { + isNonRepositoryGitStderr, makeGitVcsDriverCore, parseGitCheckoutProgressLine, splitNullSeparatedGitStdoutPaths, @@ -130,6 +131,27 @@ const makeDriverReleasingLock = ( return { driver, failures }; }); +describe("isNonRepositoryGitStderr", () => { + it.each([ + "fatal: not a git repository (or any of the parent directories): .git", + "fatal: not a git repository: /missing-git-directory", + // Git 2.56+ wording for a `.git` gitfile that points at a missing directory. + "fatal: gitfile does not point to a valid repository: /tmp/worktrees/feature/.git", + "FATAL: GITFILE DOES NOT POINT TO A VALID REPOSITORY: /tmp/x/.git", + ])("classifies %j as a non-repository", (stderr) => { + assert.isTrue(isNonRepositoryGitStderr(stderr)); + }); + + it.each([ + "", + "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.", + "fatal: Unable to create '/repo/.git/index.lock': File exists.", + "fatal: invalid gitfile format: /repo/.git", + ])("does not classify %j as a non-repository", (stderr) => { + assert.isFalse(isNonRepositoryGitStderr(stderr)); + }); +}); + it.effect("runs background Git in its own lane so user commands never queue behind it", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4fcf1b05fe6f5..c0d6f9698a233 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -562,8 +562,16 @@ function isMissingGitCwdError(error: GitCommandError): boolean { ); } -function isNonRepositoryGitStderr(stderr: string): boolean { - return stderr.toLowerCase().includes("not a git repository"); +// Git 2.56+ reports a `.git` gitfile pointing nowhere as "gitfile does not point to a valid +// repository"; older releases said "not a git repository". Both mean the cwd is not a repository. +const NON_REPOSITORY_STDERR_PHRASES = [ + "not a git repository", + "gitfile does not point to a valid repository", +] as const; + +export function isNonRepositoryGitStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return NON_REPOSITORY_STDERR_PHRASES.some((phrase) => normalized.includes(phrase)); } function isUnbornHeadStderr(stderr: string): boolean { const normalized = stderr.toLowerCase();