Add non-Git projects across filesystem mounts (#2187)

* fix(projects): allow non-git folders across mounts

Treat failed Git worktree discovery as non-Git so ordinary directories remain addable. Preserve the underlying diagnostic as a structured warning instead of failing project creation.

* fix(projects): preserve discovery warnings

Propagate checkout context through the remaining worktree discovery calls so fail-open classification retains its structured warning.
This commit is contained in:
Mohamed Boudra
2026-07-18 11:49:11 +02:00
committed by GitHub
parent a1de743ef6
commit b4a5b6a3ff
2 changed files with 43 additions and 14 deletions

View File

@@ -8,10 +8,12 @@ import {
readFileSync,
realpathSync,
mkdirSync,
statSync,
} from "fs";
import { join } from "path";
import { win32 } from "node:path";
import { tmpdir } from "os";
import pino from "pino";
import {
__resetCheckoutShortstatCacheForTests,
__resetPullRequestStatusCacheForTests,
@@ -314,6 +316,38 @@ describe("checkout git utilities", () => {
await expect(getCheckoutStatus(nonGitDir)).resolves.toEqual({ isGit: false });
});
it.runIf(
process.platform !== "win32" &&
existsSync("/dev/shm") &&
statSync("/dev").dev !== statSync("/dev/shm").dev,
)("warns and reports a non-git directory at a filesystem boundary as non-git", async () => {
const nonGitDir = realpathSync.native(mkdtempSync("/dev/shm/checkout-git-boundary-test-"));
const records: unknown[] = [];
const logger = pino(
{ level: "warn" },
{
write(line: string) {
records.push(JSON.parse(line));
},
},
);
try {
await expect(getCheckoutStatus(nonGitDir, { logger })).resolves.toEqual({ isGit: false });
expect(records).toEqual([
expect.objectContaining({
level: 40,
cwd: nonGitDir,
msg: "Git worktree discovery failed; treating directory as non-Git",
err: expect.objectContaining({
message: expect.stringContaining("Stopping at filesystem boundary"),
}),
}),
]);
} finally {
rmSync(nonGitDir, { recursive: true, force: true });
}
});
it("returns null for getCurrentBranch in a repo with no commits", async () => {
const emptyRepo = join(tempDir, "empty-repo");
mkdirSync(emptyRepo, { recursive: true });

View File

@@ -780,7 +780,7 @@ export interface MergeFromBaseOptions {
export interface CheckoutContext {
paseoHome?: string;
worktreesRoot?: string;
logger?: Pick<Logger, "trace">;
logger?: Pick<Logger, "trace" | "warn">;
facts?: CheckoutSnapshotFacts | null;
}
@@ -805,13 +805,6 @@ export type CheckoutSnapshotFacts =
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
};
function isNotGitRepositoryError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return /not a git repository \(or any of the parent directories\): \.git/i.test(error.message);
}
async function requireGitRepo(cwd: string): Promise<void> {
try {
await runGitCommand(["rev-parse", "--git-dir"], { cwd, envOverlay: READ_ONLY_GIT_ENV });
@@ -867,10 +860,12 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<
});
return parseGitRevParsePath(stdout);
} catch (error) {
if (isNotGitRepositoryError(error)) {
return null;
}
throw error;
// Git discovery is fail-open: keep the directory usable as non-Git and retain the diagnostic.
context?.logger?.warn(
{ err: error, cwd },
"Git worktree discovery failed; treating directory as non-Git",
);
return null;
}
}
@@ -1035,7 +1030,7 @@ async function getPaseoWorktreeForCwd(
return {
isPaseoOwnedWorktree: true,
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd,
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd, context)) ?? cwd,
};
}
@@ -2972,7 +2967,7 @@ export async function mergeToBase(
}
let normalizedBaseRef = baseRef;
normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef);
const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
if (normalizedBaseRef === currentBranch) {
return currentWorktreeRoot;
}