You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/10/10 10:49:10 UTC

(maka) branch main updated: fix(storage): ignore invalid ancestor Git markers (#4807)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 391990f4b4 fix(storage): ignore invalid ancestor Git markers (#4807)
391990f4b4 is described below

commit 391990f4b4f120af6c5b5d13c8b561c50a54b456
Author: faga <10...@qq.com>
AuthorDate: Sat Oct 10 18:49:05 2026 +0800

    fix(storage): ignore invalid ancestor Git markers (#4807)
    
    Generated-by: OpenAI Codex
    refactor(storage): simplify git-dir validation and share broken-ancestor fixture
    fix(storage): reject HEAD symrefs whose target is not under refs/
    
    An ancestor with valid .git/objects and .git/refs but HEAD set to
    `ref: gk` passed isGitDirectory(), while git rev-parse exits 128 on it.
    resolveProjectLocation() then threw and resolveWorkspaceIdentity()
    returned workspace_io_failed, so the malformed ancestor still blocked
    the nested-folder workflows this change is meant to recover.
    
    Git itself only accepts HEAD symrefs pointing under refs/ (verified:
    ref: gk, ref: HEAD, ref: gk/x all exit 128 even with objects/ and
    refs/ present; ref: refs/... passes even when the branch does not
    exist yet), so the check matches exactly that and no more.
    
    The case is added to the shared broken-metadata fixture as
    head-symref-no-refs-prefix, covering both the project-catalog and
    workspace-identity broken-ancestor tests.
    feat: resolve git dir with  git rev-parse --resolve-git-dir
    fix: git file no read access
    fix(storage): re-probe rejected .git ancestors under a readable stable snapshot
    
    Git's exit-128 rejection of an ancestor .git directory conflates a
    format verdict with metadata it could not actually read. Trust the
    verdict only after auditing the inputs is_git_directory consults
    (HEAD readable, objects/refs searchable) inside a stat snapshot,
    then re-probe and require the snapshot to be unchanged so repaired
    or replaced metadata cannot inherit a stale rejection.
    
    Entries that cannot be classified — commondir or
    GIT_OBJECT_DIRECTORY redirection, special files, gitfile/symlink
    indirection, and any failure on the selected directory's own .git
    — now surface as errors instead of silently downgrading to
    folder identity.
    
    Generated-by: OpenAI Codex
    docs(storage): record new git marker and workspace identity Windows exclusions
    
    Generated-by: OpenAI Codex
    fix(storage): anchor git entry probes on the entry directory
    
    assertGitEntry ran git -C process.cwd() merely to give Git a place to
    start; --resolve-git-dir judges gitPath itself. When the host's cwd has
    been deleted or is unsearchable, -C fails before the probe runs and the
    generic exit 128 is taken as Git's verdict on the entry. The audit
    re-probes under the same broken anchor, a valid enclosing repository is
    skipped, and resolveProjectLocation silently downgrades it to folder:.
    
    Anchor -C on parse(gitPath).dir instead — lstat already proved that
    directory exists — and resolve() the walk start so a relative path can
    never hand the ambient-cwd dependency back through a relative -C.
    
    Generated-by: OpenAI Codex (swe-2)
---
 docs/windows-test-inventory.md                     |  11 +-
 .../src/__tests__/fixtures/git-repository.ts       |  61 +++-
 packages/storage/src/__tests__/git-entry.test.ts   | 322 +++++++++++++++++++++
 .../storage/src/__tests__/project-catalog.test.ts  | 101 ++++++-
 .../src/__tests__/workspace-identity.test.ts       | 140 ++++++++-
 packages/storage/src/git-entry.ts                  | 151 +++++++++-
 6 files changed, 775 insertions(+), 11 deletions(-)

diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md
index 3fe2cdf1f9..1c49c743a9 100644
--- a/docs/windows-test-inventory.md
+++ b/docs/windows-test-inventory.md
@@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t
 | Classification | Count |
 |---|---:|
 | windows-backend-gap | 27 |
-| portable-candidate | 53 |
-| platform-contract | 46 |
+| portable-candidate | 56 |
+| platform-contract | 48 |
 
-Total Windows-excluded declarations: **126**
+Total Windows-excluded declarations: **131**
 
 ## Inventory
 
@@ -109,6 +109,9 @@ Total Windows-excluded declarations: **126**
 | portable-candidate | `packages/storage/src/__tests__/atomic-file-write.test.ts` creates the target 0600 on POSIX | `process.platform === 'win32'` |
 | portable-candidate | `packages/storage/src/__tests__/atomic-file-write.test.ts` re-chmods a pre-existing world-readable target to 0600 on the next write | `process.platform === 'win32'` |
 | portable-candidate | `packages/storage/src/__tests__/atomic-file-write.test.ts` refuses to write through a pre-planted symlink at the temp path | `process.platform === 'win32'` |
+| platform-contract | `packages/storage/src/__tests__/git-entry.test.ts` inaccessible ${fixture.name} cannot downgrade identity or publish a marker | `process.platform === 'win32' \|\| process.getuid?.() === 0 ? 'Requires enforced POSIX permissions' : false` |
+| portable-candidate | `packages/storage/src/__tests__/git-entry.test.ts` a dangling HEAD symlink is not evidence that HEAD is absent | `process.platform === 'win32' ? 'Requires POSIX symlink support' : false` |
+| portable-candidate | `packages/storage/src/__tests__/git-entry.test.ts` failed ancestor ${shape} is not treated as an ordinary broken directory | `shape === 'gitdir-symlink' && process.platform === 'win32'` |
 | portable-candidate | `packages/storage/src/__tests__/managed-dependency-environment.test.ts` accepts a POSIX package bin symlink whose target remains inside the dependency root | `process.platform === 'win32'` |
 | portable-candidate | `packages/storage/src/__tests__/managed-dependency-environment.test.ts` isolates published POSIX content from a producer-retained writable handle | `process.platform === 'win32'` |
 | platform-contract | `packages/storage/src/__tests__/operational-state-store.test.ts` does not classify a SQLite write failure as a migration blocker | `process.platform === 'win32' ? 'POSIX permissions are required to make the SQLite database read-only' : false` |
@@ -144,6 +147,8 @@ Total Windows-excluded declarations: **126**
 | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` hardenDirectory creates a 0700 directory chain | `process.platform === 'win32'` |
 | portable-candidate | `packages/storage/src/__tests__/stable-storage.test.ts` hardenDirectory re-chmods a pre-existing world-accessible directory to 0700 | `process.platform === 'win32'` |
 | platform-contract | `packages/storage/src/__tests__/usage-stores.test.ts` classifies a renamed or replaced live root as a draining persistence failure | `process.platform === 'win32' ? 'Windows does not permit renaming a directory with an open SQLite database' : false` |
+| platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unreadable ancestor Git repository fails closed until permissions return | `process.platform === 'win32' ? 'POSIX permissions are required for an unreadable HEAD fixture' : false` |
+| portable-candidate | `packages/storage/src/__tests__/workspace-identity.test.ts` a dangling .git symlink in the workspace itself blocks marker publication | `process.platform === 'win32' ? 'Windows symlink creation requires elevated privileges or Developer Mode' : false` |
 | platform-contract | `packages/storage/src/__tests__/workspace-identity.test.ts` an unmarked read-only workspace fails without leaving marker state | `process.platform === 'win32' ? 'POSIX permissions are required to create a read-only workspace fixture' : false` |
 | platform-contract | `scripts/macos-update-archive.test.mjs` the macOS update ZIP keeps bundle symlinks and modification times | `process.platform !== 'darwin' && 'the macOS update ZIP is built on macOS'` |
 | portable-candidate | `scripts/qualify-released-cli-state-root.test.mjs` starts the liveness window after a delayed Runtime Host Ready | `process.platform === 'win32'` |
diff --git a/packages/storage/src/__tests__/fixtures/git-repository.ts b/packages/storage/src/__tests__/fixtures/git-repository.ts
index 98cacbc201..ca233b5c78 100644
--- a/packages/storage/src/__tests__/fixtures/git-repository.ts
+++ b/packages/storage/src/__tests__/fixtures/git-repository.ts
@@ -18,12 +18,71 @@
  */
 
 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_DIRECTORY_SHAPES = [
+  'missing-head',
+  'head-directory',
+  'head-garbage',
+  'head-symref-no-refs-prefix',
+  'missing-objects-and-refs',
+  'missing-objects',
+  'missing-refs',
+  'objects-file',
+  'refs-file',
+] as const;
+
+export const BROKEN_GIT_SHAPES = [...BROKEN_GIT_DIRECTORY_SHAPES, 'gitfile-garbage-head'] 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<void> {
+  await execFileAsync('git', ['init', '--quiet'], { cwd: root });
+  switch (shape) {
+    case 'missing-head':
+      await rm(join(root, '.git', 'HEAD'));
+      return;
+    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 });
+      return;
+    case 'missing-objects':
+    case 'missing-refs':
+      await rm(join(root, '.git', shape === 'missing-objects' ? 'objects' : 'refs'), {
+        recursive: true,
+      });
+      return;
+    case 'objects-file':
+    case 'refs-file': {
+      const path = join(root, '.git', shape === 'objects-file' ? 'objects' : 'refs');
+      await rm(path, { recursive: true });
+      await writeFile(path, 'not a directory\n');
+    }
+  }
+}
+
 export async function createGitRepositoryWithWorktree(
   repository: string,
   linkedWorktree: string,
diff --git a/packages/storage/src/__tests__/git-entry.test.ts b/packages/storage/src/__tests__/git-entry.test.ts
new file mode 100644
index 0000000000..ff412641b0
--- /dev/null
+++ b/packages/storage/src/__tests__/git-entry.test.ts
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { execFile } from 'node:child_process';
+import fsPromises, {
+  access,
+  chmod,
+  mkdir,
+  mkdtemp,
+  realpath,
+  rm,
+  symlink,
+  writeFile,
+} from 'node:fs/promises';
+import { syncBuiltinESMExports } from 'node:module';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { promisify } from 'node:util';
+
+import { hasEnclosingGitEntry } from '../git-entry.js';
+import { createProjectCatalog, resolveProjectLocation } from '../project-catalog.js';
+import {
+  resolveWorkspaceIdentity,
+  WORKSPACE_MARKER_FILE,
+  WorkspaceIdentityError,
+} from '../workspace-identity.js';
+import {
+  BROKEN_GIT_DIRECTORY_SHAPES,
+  BROKEN_GIT_SHAPES,
+  createBrokenGitMetadata,
+  createGitRepositoryWithWorktree,
+} from './fixtures/git-repository.js';
+
+const execFileAsync = promisify(execFile);
+
+for (const shape of BROKEN_GIT_SHAPES) {
+  test(`own ${shape} metadata cannot be hidden by a valid outer repository`, async () => {
+    const base = await mkdtemp(join(tmpdir(), 'maka-git-own-entry-'));
+    const catalog = createProjectCatalog(join(base, 'state'));
+    try {
+      const repository = join(base, 'repository');
+      const selected = join(repository, 'selected');
+      await mkdir(selected, { recursive: true });
+      await execFileAsync('git', ['init', '--quiet'], { cwd: repository });
+      await createBrokenGitMetadata(selected, shape);
+
+      await assert.rejects(resolveProjectLocation({ path: selected, intent: 'selected' }));
+      await assert.rejects(catalog.register(selected));
+      assert.deepEqual(await catalog.list(), []);
+      await assert.rejects(resolveWorkspaceIdentity({ path: selected }), {
+        code: 'workspace_io_failed',
+      });
+      await assert.rejects(access(join(selected, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' });
+    } finally {
+      catalog.close();
+      await rm(base, { recursive: true, force: true });
+    }
+  });
+}
+
+const permissionCases = [
+  { name: 'HEAD', linked: false, target: 'HEAD', mode: 0o000, restore: 0o644 },
+  { name: 'gitdir', linked: false, target: '', mode: 0o000, restore: 0o755 },
+  { name: 'objects traversal', linked: false, target: 'objects', mode: 0o444, restore: 0o755 },
+  { name: 'refs traversal', linked: false, target: 'refs', mode: 0o444, restore: 0o755 },
+  { name: 'commondir', linked: true, target: 'commondir', mode: 0o000, restore: 0o644 },
+  { name: 'shared objects', linked: true, target: 'objects', mode: 0o000, restore: 0o755 },
+];
+
+for (const fixture of permissionCases) {
+  test(`inaccessible ${fixture.name} cannot downgrade identity or publish a marker`, {
+    skip:
+      process.platform === 'win32' || process.getuid?.() === 0
+        ? 'Requires enforced POSIX permissions'
+        : false,
+  }, async () => {
+    const base = await mkdtemp(join(tmpdir(), 'maka-git-permissions-'));
+    const catalog = createProjectCatalog(join(base, 'state'));
+    let restrictedPath: string | undefined;
+    try {
+      const repository = join(base, 'repository');
+      const linked = join(base, 'linked');
+      await createGitRepositoryWithWorktree(repository, linked, 'permissions');
+      const root = fixture.linked ? linked : repository;
+      const workspace = join(root, 'nested');
+      await mkdir(workspace);
+      const expected = await resolveProjectLocation({ path: workspace });
+      const { stdout } = await execFileAsync('git', ['rev-parse', '--absolute-git-dir'], {
+        cwd: root,
+        encoding: 'utf8',
+      });
+      restrictedPath =
+        fixture.target === 'objects'
+          ? join(repository, '.git', 'objects')
+          : join(stdout.trim(), fixture.target);
+      await chmod(restrictedPath, fixture.mode);
+
+      await assert.rejects(resolveProjectLocation({ path: workspace }));
+      await assert.rejects(catalog.register(workspace));
+      assert.deepEqual(await catalog.list(), []);
+      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' });
+
+      await chmod(restrictedPath, fixture.restore);
+      assert.deepEqual(await resolveProjectLocation({ path: workspace }), expected);
+      await resolveWorkspaceIdentity({ path: workspace });
+      await access(join(workspace, WORKSPACE_MARKER_FILE));
+      const { stdout: status } = await execFileAsync(
+        'git',
+        ['status', '--porcelain=v1', '--untracked-files=all'],
+        { cwd: root },
+      );
+      assert.equal(status, '');
+    } finally {
+      if (restrictedPath) await chmod(restrictedPath, fixture.restore);
+      catalog.close();
+      await rm(base, { recursive: true, force: true });
+    }
+  });
+}
+
+test('a dangling HEAD symlink is not evidence that HEAD is absent', {
+  skip: process.platform === 'win32' ? 'Requires POSIX symlink support' : false,
+}, async () => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-git-head-symlink-'));
+  try {
+    const workspace = join(base, 'workspace');
+    await mkdir(workspace);
+    await mkdir(join(base, '.git'));
+    await symlink('missing-target', join(base, '.git', 'HEAD'));
+    await assert.rejects(hasEnclosingGitEntry(workspace));
+  } finally {
+    await rm(base, { recursive: true, force: true });
+  }
+});
+
+for (const shape of BROKEN_GIT_DIRECTORY_SHAPES) {
+  test(`ancestor ${shape} still discovers and excludes through an outer repository`, async () => {
+    const base = await mkdtemp(join(tmpdir(), 'maka-git-outer-entry-'));
+    const catalog = createProjectCatalog(join(base, 'state'));
+    try {
+      const repository = join(base, 'repository');
+      const broken = join(repository, 'broken');
+      const workspace = join(broken, 'workspace');
+      await mkdir(workspace, { recursive: true });
+      await execFileAsync('git', ['init', '--quiet'], { cwd: repository });
+      await createBrokenGitMetadata(broken, shape);
+
+      const resolved = await resolveProjectLocation({ path: workspace });
+      assert.equal(resolved.kind, 'git');
+      assert.equal(resolved.git?.worktreeRoot, await realpath(repository));
+      // Explicit selection keeps folder identity, but Git still owns exclusion.
+      const project = await catalog.register(workspace);
+      assert.deepEqual(project.locations, [{ path: await realpath(workspace), isWorktree: false }]);
+      await resolveWorkspaceIdentity({ path: workspace });
+      const { stdout: ignored } = await execFileAsync(
+        'git',
+        ['check-ignore', WORKSPACE_MARKER_FILE],
+        { cwd: workspace },
+      );
+      assert.equal(ignored.trim(), WORKSPACE_MARKER_FILE);
+      const { stdout: status } = await execFileAsync(
+        'git',
+        ['status', '--porcelain=v1', '--untracked-files=all'],
+        { cwd: repository },
+      );
+      assert.equal(status, '');
+    } finally {
+      catalog.close();
+      await rm(base, { recursive: true, force: true });
+    }
+  });
+}
+
+for (const shape of ['gitfile-garbage-head', 'gitdir-symlink', 'commondir'] as const) {
+  test(`failed ancestor ${shape} is not treated as an ordinary broken directory`, {
+    skip: shape === 'gitdir-symlink' && process.platform === 'win32',
+  }, async () => {
+    const base = await mkdtemp(join(tmpdir(), 'maka-git-indirect-entry-'));
+    const catalog = createProjectCatalog(join(base, 'state'));
+    try {
+      const workspace = join(base, 'workspace');
+      await mkdir(workspace);
+      if (shape === 'gitfile-garbage-head') {
+        await createBrokenGitMetadata(base, shape);
+      } else if (shape === 'gitdir-symlink') {
+        const target = join(base, 'metadata');
+        await mkdir(target);
+        await symlink(target, join(base, '.git'));
+      } else {
+        await createBrokenGitMetadata(base, 'head-garbage');
+        await writeFile(join(base, '.git', 'commondir'), '../shared\n');
+      }
+      await assert.rejects(resolveProjectLocation({ path: workspace }));
+      await assert.rejects(catalog.register(workspace));
+      assert.deepEqual(await catalog.list(), []);
+      await assert.rejects(resolveWorkspaceIdentity({ path: workspace }), {
+        code: 'workspace_io_failed',
+      });
+      await assert.rejects(access(join(workspace, WORKSPACE_MARKER_FILE)), { code: 'ENOENT' });
+    } finally {
+      catalog.close();
+      await rm(base, { recursive: true, force: true });
+    }
+  });
+}
+
+test('a HEAD read failure is not a format verdict even when access checks succeed', async (t) => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-git-read-failure-'));
+  const originalOpen = fsPromises.open;
+  try {
+    const workspace = join(base, 'workspace');
+    const head = join(base, '.git', 'HEAD');
+    await mkdir(workspace);
+    await createBrokenGitMetadata(base, 'head-garbage');
+    const failure = Object.assign(new Error('Injected HEAD read failure'), { code: 'EIO' });
+    t.mock.method(fsPromises, 'open', async (path: string, flags: number) => {
+      const file = await originalOpen(path, flags);
+      if (path === head) {
+        t.mock.method(file, 'read', async () => {
+          throw failure;
+        });
+      }
+      return file;
+    });
+    syncBuiltinESMExports();
+    await assert.rejects(hasEnclosingGitEntry(workspace), (error: unknown) => error === failure);
+  } finally {
+    t.mock.restoreAll();
+    syncBuiltinESMExports();
+    await rm(base, { recursive: true, force: true });
+  }
+});
+
+test('metadata changed during validation cannot inherit an earlier rejection', async (t) => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-git-changing-head-'));
+  const originalStat = fsPromises.lstat;
+  try {
+    const workspace = join(base, 'workspace');
+    const head = join(base, '.git', 'HEAD');
+    await mkdir(workspace);
+    await createBrokenGitMetadata(base, 'head-garbage');
+    let observations = 0;
+    t.mock.method(fsPromises, 'lstat', async (path: string) => {
+      if (path === head && ++observations === 2) {
+        await writeFile(head, 'ref: refs/heads/repaired\n');
+      }
+      return originalStat(path);
+    });
+    syncBuiltinESMExports();
+    await assert.rejects(hasEnclosingGitEntry(workspace), /Git metadata changed during discovery/);
+    assert.equal(observations, 2);
+  } finally {
+    t.mock.restoreAll();
+    syncBuiltinESMExports();
+    await rm(base, { recursive: true, force: true });
+  }
+});
+
+test('an object-directory override cannot be mistaken for local metadata corruption', async () => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-git-object-override-'));
+  try {
+    const workspace = join(base, 'workspace');
+    await mkdir(workspace);
+    await execFileAsync('git', ['init', '--quiet'], { cwd: base });
+    const moduleUrl = new URL('../git-entry.js', import.meta.url).href;
+    await execFileAsync(
+      process.execPath,
+      [
+        '--input-type=module',
+        '-e',
+        `import assert from 'node:assert/strict';
+         import { hasEnclosingGitEntry } from ${JSON.stringify(moduleUrl)};
+         await assert.rejects(hasEnclosingGitEntry(${JSON.stringify(workspace)}));`,
+      ],
+      { env: { ...process.env, GIT_OBJECT_DIRECTORY: join(base, 'missing-objects') } },
+    );
+  } finally {
+    await rm(base, { recursive: true, force: true });
+  }
+});
+
+test('an unreachable ambient cwd cannot veto an enclosing repository', async () => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-git-ambient-cwd-'));
+  const originalCwd = process.cwd;
+  try {
+    const repository = join(base, 'repository');
+    const workspace = join(repository, 'workspace');
+    await mkdir(workspace, { recursive: true });
+    await execFileAsync('git', ['init', '--quiet'], { cwd: repository });
+    // The probe once ran git -C process.cwd(); an ambient cwd that survives
+    // only as a stale string (deleted or unsearchable) makes -C fail with a
+    // generic 128 that was mistaken for Git's verdict on the entry itself.
+    process.cwd = () => join(base, 'removed');
+    assert.equal(await hasEnclosingGitEntry(workspace), true);
+  } finally {
+    process.cwd = originalCwd;
+    await rm(base, { recursive: true, force: true });
+  }
+});
diff --git a/packages/storage/src/__tests__/project-catalog.test.ts b/packages/storage/src/__tests__/project-catalog.test.ts
index 33493edfb2..6d4acf0958 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_DIRECTORY_SHAPES,
+  createBrokenGitMetadata,
+  createGitRepositoryWithWorktree,
+} from './fixtures/git-repository.js';
 
 const execFileAsync = promisify(execFile);
 const trackedCatalogs = new Map<ProjectCatalog, string>();
@@ -172,6 +176,101 @@ 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',
+    });
+    const catalog = createProjectCatalog(join(base, 'state'));
+    const project = await catalog.register(folder);
+    assert.deepEqual(project.locations, [{ path: await realpath(folder), isWorktree: false }]);
+    assert.deepEqual(await catalog.list(), [project]);
+    // Missing Git is a probe failure, even for an otherwise ignorable shape.
+    await assert.rejects(resolveProjectLocationWithoutGit(folder));
+  } finally {
+    await rm(base, { recursive: true, force: true });
+  }
+});
+
+test('broken ancestor Git directories do 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_DIRECTORY_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'));
+      try {
+        const project = await catalog.register(folder);
+        assert.deepEqual(
+          project.locations,
+          [{ path: await realpath(folder), isWorktree: false }],
+          shape,
+        );
+        assert.deepEqual(await catalog.list(), [project]);
+      } finally {
+        catalog.close();
+      }
+    }
+  } 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..055eeae7f5 100644
--- a/packages/storage/src/__tests__/workspace-identity.test.ts
+++ b/packages/storage/src/__tests__/workspace-identity.test.ts
@@ -19,7 +19,17 @@
 
 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,
+  realpath,
+  rm,
+  symlink,
+  writeFile,
+} from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { test } from 'node:test';
@@ -31,6 +41,7 @@ import {
   WORKSPACE_MARKER_FILE,
   WorkspaceIdentityError,
 } from '../workspace-identity.js';
+import { BROKEN_GIT_DIRECTORY_SHAPES, createBrokenGitMetadata } from './fixtures/git-repository.js';
 
 const execFileAsync = promisify(execFile);
 
@@ -200,6 +211,112 @@ 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 directories do not block a workspace marker', async () => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-workspace-invalid-ancestor-'));
+  try {
+    for (const shape of BROKEN_GIT_DIRECTORY_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 valid outer repository owns exclusion for a workspace below broken ancestor metadata', async () => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-workspace-git-outer-'));
+  try {
+    const repository = join(base, 'repository');
+    const broken = join(repository, 'broken');
+    const workspace = join(broken, 'workspace');
+    await mkdir(workspace, { recursive: true });
+    await execFileAsync('git', ['init', '--quiet'], { cwd: repository });
+    await createBrokenGitMetadata(broken, 'head-garbage');
+
+    await resolveWorkspaceIdentity({ path: workspace });
+    await access(join(workspace, WORKSPACE_MARKER_FILE));
+
+    // Git itself still selects the outer repository from the nested workspace;
+    // the marker must be ignored through that repository's local exclude.
+    const repoRoot = await realpath(repository);
+    const { stdout: toplevel } = await execFileAsync(
+      'git',
+      ['-C', workspace, 'rev-parse', '--show-toplevel'],
+      { encoding: 'utf8' },
+    );
+    assert.equal(toplevel.trim(), repoRoot);
+    const { stdout: exclude } = await execFileAsync(
+      'git',
+      ['-C', workspace, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'],
+      { encoding: 'utf8' },
+    );
+    assert.equal(exclude.trim(), join(repoRoot, '.git', 'info', 'exclude'));
+    const { stdout } = await execFileAsync(
+      'git',
+      ['status', '--porcelain=v1', '--untracked-files=all'],
+      { cwd: repository, encoding: 'utf8' },
+    );
+    assert.equal(stdout, '');
+  } finally {
+    await rm(base, { recursive: true, force: true });
+  }
+});
+
+test('an unreadable ancestor Git repository fails closed until permissions return', {
+  skip:
+    process.platform === 'win32'
+      ? 'POSIX permissions are required for an unreadable HEAD fixture'
+      : false,
+}, async () => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-workspace-git-unreadable-ancestor-'));
+  try {
+    const repository = join(base, 'repository');
+    const workspace = join(repository, 'nested', 'workspace');
+    await mkdir(workspace, { recursive: true });
+    await execFileAsync('git', ['init', '--quiet'], { cwd: repository });
+    await chmod(join(repository, '.git', 'HEAD'), 0o000);
+
+    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' });
+
+    await chmod(join(repository, '.git', 'HEAD'), 0o644);
+    await resolveWorkspaceIdentity({ path: workspace });
+    await access(join(workspace, WORKSPACE_MARKER_FILE));
+    const { stdout } = await execFileAsync(
+      'git',
+      ['status', '--porcelain=v1', '--untracked-files=all'],
+      { cwd: repository, encoding: 'utf8' },
+    );
+    assert.equal(stdout, '');
+  } 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 +333,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..c46e88dda1 100644
--- a/packages/storage/src/git-entry.ts
+++ b/packages/storage/src/git-entry.ts
@@ -17,21 +17,162 @@
  * under the License.
  */
 
-import { lstat } from 'node:fs/promises';
-import { join, parse } from 'node:path';
+import { constants as fsConstants, type Stats } from 'node:fs';
+import { access, lstat, open } from 'node:fs/promises';
+import { join, parse, resolve } from 'node:path';
 
+import { execGitText } from './git-exec.js';
+
+/**
+ * True means Git recognized an entry, not that every repository operation will
+ * succeed. False requires exhausting the ancestors; ambiguous failures throw.
+ */
 export async function hasEnclosingGitEntry(path: string): Promise<boolean> {
-  let current = path;
+  // The git probes below anchor on entry directories, so the walk needs an
+  // absolute path: a relative parent would ask Git to resolve -C against the
+  // child's inherited cwd, which may be deleted or unsearchable.
+  let current = resolve(path);
   while (true) {
+    const gitPath = join(current, '.git');
+    let entryStat: Stats | undefined;
     try {
-      await lstat(join(current, '.git'));
-      return true;
+      entryStat = await lstat(gitPath);
     } catch (error) {
       const code = (error as NodeJS.ErrnoException).code;
       if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
     }
+    if (entryStat && (await isGitEntry(gitPath, entryStat, current !== path))) {
+      return true;
+    }
+    // Git skips invalid ancestor directories, but gitfiles can stop discovery.
     const parent = parse(current).dir;
     if (parent === current) return false;
     current = parent;
   }
 }
+
+async function isGitEntry(gitPath: string, before: Stats, ancestor: boolean): Promise<boolean> {
+  try {
+    // Validate the exact entry: discovery could hide a broken own marker by
+    // finding an outer repository instead.
+    await assertGitEntry(gitPath);
+    return true;
+  } catch (error) {
+    if (!ancestor || !before.isDirectory() || (error as { code?: unknown }).code !== 128) {
+      throw error;
+    }
+  }
+
+  // Preserve Git's format verdict, but only after auditing its recognition
+  // inputs. Repeat the probe inside the snapshot checks rather than applying
+  // a stale rejection to metadata that may have been repaired or replaced.
+  const snapshot = await assertGitDirectoryReadable(gitPath, before);
+  let recognized = true;
+  try {
+    await assertGitEntry(gitPath);
+  } catch (error) {
+    if ((error as { code?: unknown }).code !== 128) throw error;
+    recognized = false;
+  }
+  for (const [path, stat] of snapshot) {
+    assertUnchanged(path, stat, await statIfPresent(path));
+  }
+  return recognized;
+}
+
+async function assertGitEntry(gitPath: string): Promise<void> {
+  // Let Git interpret directories and gitfiles, including linked worktrees. -C
+  // only chooses where Git starts; --resolve-git-dir judges gitPath itself.
+  // Anchor on the directory containing the entry (lstat proved it exists)
+  // instead of the host's ambient cwd, which may have been deleted or made
+  // unsearchable since the process started.
+  await execGitText(parse(gitPath).dir, ['rev-parse', '--resolve-git-dir', gitPath], {
+    maxBuffer: 64 * 1024,
+    timeoutMs: 3_000,
+  });
+}
+
+/**
+ * Audit the ordinary directory layout recognized by Git's is_git_directory:
+ * HEAD is read (at most 255 bytes), and objects/refs require search permission.
+ * Missing members and wrong ordinary-file/directory types are format failures;
+ * permission, read and unexpected filesystem errors must still surface.
+ * Failed probes involving indirection remain unclassified: success for valid
+ * worktrees/symlinks is handled by Git above, not by this fallback.
+ */
+async function assertGitDirectoryReadable(
+  gitPath: string,
+  before: Stats,
+): Promise<Map<string, Stats | undefined>> {
+  await access(gitPath, fsConstants.R_OK | fsConstants.X_OK);
+  const snapshot = new Map<string, Stats | undefined>([[gitPath, before]]);
+  const commonPath = join(gitPath, 'commondir');
+  const commonStat = await statIfPresent(commonPath);
+  if (commonStat || process.env.GIT_OBJECT_DIRECTORY !== undefined) {
+    throw new Error(`Cannot classify Git metadata with redirected storage: ${gitPath}`);
+  }
+  snapshot.set(commonPath, commonStat);
+  for (const name of ['HEAD', 'objects', 'refs']) {
+    const path = join(gitPath, name);
+    const stat = await statIfPresent(path);
+    snapshot.set(path, stat);
+    if (!stat) continue;
+    if (stat.isDirectory()) {
+      await access(path, fsConstants.R_OK | fsConstants.X_OK);
+    } else if (stat.isFile()) {
+      await access(path, fsConstants.R_OK);
+      if (name === 'HEAD') await assertHeadReadable(path, stat);
+    } else {
+      throw new Error(`Cannot classify Git metadata with special file: ${path}`);
+    }
+  }
+  return snapshot;
+}
+
+async function assertHeadReadable(path: string, before: Stats): Promise<void> {
+  // Do not follow a replacement symlink or block opening a replacement FIFO.
+  const file = await open(
+    path,
+    fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK,
+  );
+  try {
+    assertUnchanged(path, before, await file.stat());
+    const buffer = Buffer.alloc(255);
+    let offset = 0;
+    while (offset < buffer.length) {
+      const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset);
+      if (bytesRead === 0) break;
+      offset += bytesRead;
+    }
+    assertUnchanged(path, before, await file.stat());
+  } finally {
+    await file.close();
+  }
+}
+
+async function statIfPresent(path: string): Promise<Stats | undefined> {
+  try {
+    // lstat distinguishes a dangling symlink from a missing member. ENOTDIR
+    // here means a parent changed, not that the member was simply absent.
+    return await lstat(path);
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+    return undefined;
+  }
+}
+
+function assertUnchanged(path: string, before: Stats | undefined, after: Stats | undefined): void {
+  if (!before && !after) return;
+  if (
+    !before ||
+    !after ||
+    before.dev !== after.dev ||
+    before.ino !== after.ino ||
+    before.mode !== after.mode ||
+    before.size !== after.size ||
+    before.mtimeMs !== after.mtimeMs ||
+    before.ctimeMs !== after.ctimeMs
+  ) {
+    throw new Error(`Git metadata changed during discovery: ${path}`);
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org