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