diff --git a/packages/storage/src/__tests__/fixtures/git-repository.ts b/packages/storage/src/__tests__/fixtures/git-repository.ts index 98cacbc201..8491d74541 100644 --- a/packages/storage/src/__tests__/fixtures/git-repository.ts +++ b/packages/storage/src/__tests__/fixtures/git-repository.ts @@ -18,12 +18,49 @@ */ import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +export const BROKEN_GIT_SHAPES = [ + 'head-directory', + 'head-garbage', + 'head-symref-no-refs-prefix', + 'gitfile-garbage-head', + 'missing-objects-and-refs', +] as const; + +export type BrokenGitShape = (typeof BROKEN_GIT_SHAPES)[number]; + +/** Writes structurally broken Git metadata (a `.git` entry or its target) into root. */ +export async function createBrokenGitMetadata(root: string, shape: BrokenGitShape): Promise { + await execFileAsync('git', ['init', '--quiet'], { cwd: root }); + switch (shape) { + case 'head-directory': + await rm(join(root, '.git', 'HEAD')); + await mkdir(join(root, '.git', 'HEAD')); + return; + case 'head-garbage': + await writeFile(join(root, '.git', 'HEAD'), 'gk\n', 'utf8'); + return; + case 'gitfile-garbage-head': + await rm(join(root, '.git'), { recursive: true }); + await writeFile(join(root, '.git'), 'gitdir: stub\n', 'utf8'); + await mkdir(join(root, 'stub')); + await execFileAsync('git', ['init', '--bare', '--quiet', join(root, 'stub')]); + await writeFile(join(root, 'stub', 'HEAD'), 'gk\n', 'utf8'); + return; + case 'head-symref-no-refs-prefix': + await writeFile(join(root, '.git', 'HEAD'), 'ref: gk\n', 'utf8'); + return; + case 'missing-objects-and-refs': + await rm(join(root, '.git', 'objects'), { recursive: true }); + await rm(join(root, '.git', 'refs'), { recursive: true }); + } +} + export async function createGitRepositoryWithWorktree( repository: string, linkedWorktree: string, diff --git a/packages/storage/src/__tests__/project-catalog.test.ts b/packages/storage/src/__tests__/project-catalog.test.ts index 6348662534..eaeff41bd8 100644 --- a/packages/storage/src/__tests__/project-catalog.test.ts +++ b/packages/storage/src/__tests__/project-catalog.test.ts @@ -35,7 +35,11 @@ import { resolveProjectLocation, } from '../project-catalog.js'; import { createSessionStore } from '../session-store.js'; -import { createGitRepositoryWithWorktree } from './fixtures/git-repository.js'; +import { + BROKEN_GIT_SHAPES, + createBrokenGitMetadata, + createGitRepositoryWithWorktree, +} from './fixtures/git-repository.js'; const execFileAsync = promisify(execFile); const trackedCatalogs = new Map(); @@ -99,6 +103,91 @@ test('a plain folder resolves without requiring the Git executable', async () => } }); +test('an incomplete enclosing .git directory does not turn a nested folder into a repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-folder-invalid-git-')); + try { + const folder = join(base, 'folder'); + await mkdir(join(base, '.git', 'gk'), { recursive: true }); + await mkdir(folder); + + assert.deepEqual(await resolveProjectLocation({ path: folder }), { + canonicalPath: await realpath(folder), + identity: `folder:${await realpath(folder)}`, + kind: 'folder', + }); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('broken ancestor Git metadata does not turn a nested folder into a repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-invalid-ancestor-')); + try { + for (const shape of BROKEN_GIT_SHAPES) { + const root = join(base, shape); + const folder = join(root, 'folder'); + await mkdir(folder, { recursive: true }); + await createBrokenGitMetadata(root, shape); + + assert.deepEqual( + await resolveProjectLocation({ path: folder }), + { + canonicalPath: await realpath(folder), + identity: `folder:${await realpath(folder)}`, + kind: 'folder', + }, + shape, + ); + const catalog = createProjectCatalog(join(root, 'state')); + const project = await catalog.register(folder); + assert.deepEqual( + project.locations, + [{ path: await realpath(folder), isWorktree: false }], + shape, + ); + assert.deepEqual(await catalog.list(), [project]); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('a folder nested inside a repository resolves to that repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-in-repo-')); + try { + const repository = join(base, 'repository'); + const nested = join(repository, 'sub', 'dir'); + await mkdir(nested, { recursive: true }); + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }); + + const resolved = await resolveProjectLocation({ path: nested }); + + assert.equal(resolved.kind, 'git'); + assert.equal(resolved.git?.worktreeRoot, await realpath(repository)); + await assert.rejects(resolveProjectLocationWithoutGit(nested)); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('a folder nested inside a linked worktree resolves to that repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-in-worktree-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'nested-linked'); + const nested = join(linkedWorktree, 'nested'); + await mkdir(nested); + + const resolved = await resolveProjectLocation({ path: nested }); + + assert.equal(resolved.kind, 'git'); + assert.equal(resolved.git?.isWorktree, true); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + test('a Git probe failure cannot persistently downgrade a repository to a folder', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-project-repository-no-git-')); try { diff --git a/packages/storage/src/__tests__/workspace-identity.test.ts b/packages/storage/src/__tests__/workspace-identity.test.ts index 22ed636cff..650b199569 100644 --- a/packages/storage/src/__tests__/workspace-identity.test.ts +++ b/packages/storage/src/__tests__/workspace-identity.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -31,6 +31,7 @@ import { WORKSPACE_MARKER_FILE, WorkspaceIdentityError, } from '../workspace-identity.js'; +import { BROKEN_GIT_SHAPES, createBrokenGitMetadata } from './fixtures/git-repository.js'; const execFileAsync = promisify(execFile); @@ -200,6 +201,38 @@ test('a full Git exclude does not grow when resolving workspace identity', async } }); +test('a malformed ancestor .git directory does not block a workspace marker', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-workspace-git-malformed-ancestor-')); + try { + const workspace = join(base, 'workspace'); + await mkdir(join(base, '.git', 'gk'), { recursive: true }); + await mkdir(workspace); + + await resolveWorkspaceIdentity({ path: workspace }); + + await access(join(workspace, WORKSPACE_MARKER_FILE)); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('broken ancestor Git metadata does not block a workspace marker', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-workspace-invalid-ancestor-')); + try { + for (const shape of BROKEN_GIT_SHAPES) { + const root = join(base, shape); + const workspace = join(root, 'workspace'); + await mkdir(workspace, { recursive: true }); + await createBrokenGitMetadata(root, shape); + + await resolveWorkspaceIdentity({ path: workspace }); + await access(join(workspace, WORKSPACE_MARKER_FILE)); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + test('a malformed enclosing Git repository prevents publishing a new marker', async () => { const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-malformed-')); try { @@ -216,6 +249,27 @@ test('a malformed enclosing Git repository prevents publishing a new marker', as } }); +test('a dangling .git symlink in the workspace itself blocks marker publication', { + skip: + process.platform === 'win32' + ? 'Windows symlink creation requires elevated privileges or Developer Mode' + : false, +}, async () => { + const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-git-dangling-')); + try { + await symlink(join(workspace, 'missing-target'), join(workspace, '.git')); + + await assert.rejects( + () => resolveWorkspaceIdentity({ path: workspace }), + (error: unknown) => + error instanceof WorkspaceIdentityError && error.code === 'workspace_io_failed', + ); + await assert.rejects(access(join(workspace, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + test('a non-Git workspace resolves when the Git executable is unavailable', async () => { const workspace = await mkdtemp(join(tmpdir(), 'maka-workspace-no-git-required-')); try { diff --git a/packages/storage/src/git-entry.ts b/packages/storage/src/git-entry.ts index db998487b6..527ad107ec 100644 --- a/packages/storage/src/git-entry.ts +++ b/packages/storage/src/git-entry.ts @@ -17,15 +17,22 @@ * under the License. */ +import { execFile } from 'node:child_process'; import { lstat } from 'node:fs/promises'; import { join, parse } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); export async function hasEnclosingGitEntry(path: string): Promise { let current = path; while (true) { + const gitPath = join(current, '.git'); try { - await lstat(join(current, '.git')); - return true; + await lstat(gitPath); + // Keep failures in the selected directory's own metadata visible. + if (current === path) return true; + return isGitEntry(gitPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; @@ -35,3 +42,27 @@ export async function hasEnclosingGitEntry(path: string): Promise { current = parent; } } + +async function isGitEntry(gitPath: string): Promise { + const env: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: '0' }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + delete env.GIT_INDEX_FILE; + delete env.GIT_COMMON_DIR; + try { + // Let Git validate directories and gitfiles, including linked worktrees. + await execFileAsync('git', ['rev-parse', '--resolve-git-dir', gitPath], { + env, + encoding: 'utf8', + maxBuffer: 64 * 1024, + timeout: 3_000, + windowsHide: true, + }); + return true; + } catch (error) { + // This probe exits 128 for invalid Git metadata. Execution failures must + // still surface so a missing Git executable cannot downgrade a repository. + if ((error as { code?: unknown }).code === 128) return false; + throw error; + } +}