Merge branch 'main' into feat/Desktop-app

This commit is contained in:
Gergő Magyar 2026-05-14 17:30:40 +01:00 committed by GitHub
commit 51170d5bb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 230 additions and 6 deletions

View file

@ -11,6 +11,7 @@ import { realpathSync } from 'fs';
import path from 'path';
import os from 'os';
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
import { logger } from '../core/logger.js';
/**
* Normalise a repo path for registry comparison across platforms
@ -281,14 +282,44 @@ export const findRepo = async (startPath: string): Promise<IndexedRepo | null> =
return null;
};
function isReadOnlyFilesystemError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
}
/**
* Keep generated index files ignored without modifying the user's root .gitignore.
*/
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
const desired = '*\n';
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
await fs.writeFile(gitignorePath, '*\n', 'utf-8');
// Idempotent fast path: skip the write entirely when the file already has
// the expected content. Lets this run cleanly on read-only mounts (e.g.
// the documented Docker workflow with WORKSPACE_DIR bound :ro) when an
// earlier `analyze` already created the file. See issue #1549.
try {
if ((await fs.readFile(gitignorePath, 'utf-8')) === desired) {
await ensureGitInfoExclude(repoPath);
return;
}
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
try {
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
await fs.writeFile(gitignorePath, desired, 'utf-8');
} catch (err: any) {
if (isReadOnlyFilesystemError(err)) {
logger.warn(
{ path: gitignorePath, code: err.code },
'GitNexus storage filesystem is not writable; skipping .gitnexus/.gitignore. Generated files may appear as untracked in this repo locally.',
);
} else {
throw err;
}
}
await ensureGitInfoExclude(repoPath);
};
@ -304,8 +335,6 @@ const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
return;
}
await fs.mkdir(path.dirname(excludePath), { recursive: true });
let content = '';
try {
content = await fs.readFile(excludePath, 'utf-8');
@ -320,7 +349,19 @@ const ensureGitInfoExclude = async (repoPath: string): Promise<void> => {
if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return;
const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n';
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
try {
await fs.mkdir(path.dirname(excludePath), { recursive: true });
await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8');
} catch (err: any) {
if (isReadOnlyFilesystemError(err)) {
logger.warn(
{ path: excludePath, code: err.code },
'GitNexus storage filesystem is not writable; skipping .git/info/exclude update. .gitnexus/ may appear as untracked in `git status` locally.',
);
} else {
throw err;
}
}
};
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────

View file

@ -0,0 +1,113 @@
/**
* Read-only / permission-denied write paths for ensureGitNexusIgnored (#1549, PR #1550).
* Separate from repo-manager.test.ts: Vitest cannot vi.spyOn ESM namespace exports of
* fs/promises; a delegating vi.mock is required for cross-platform mock rejects.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'path';
const fswCtx = vi.hoisted(() => ({
writeFileMock: vi.fn(),
realWrite: null as ((...args: unknown[]) => Promise<unknown>) | null,
}));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
const d = actual.default;
fswCtx.realWrite = d.writeFile.bind(d);
fswCtx.writeFileMock.mockImplementation((...args) => fswCtx.realWrite!(...args));
return {
default: new Proxy(d, {
get(target, prop) {
if (prop === 'writeFile') return fswCtx.writeFileMock;
const v = Reflect.get(target, prop, target) as unknown;
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
},
}),
};
});
import fs from 'fs/promises';
import { ensureGitNexusIgnored } from '../../src/storage/repo-manager.js';
import { _captureLogger } from '../../src/core/logger.js';
import { createTempDir } from '../helpers/test-db.js';
const samePath = (a: string, b: string) => path.normalize(a) === path.normalize(b);
describe('ensureGitNexusIgnored — mocked writeFile (EROFS / EACCES / EPERM)', () => {
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
beforeEach(async () => {
tmpRepo = await createTempDir('gitnexus-ro-ignore-mock-');
fswCtx.writeFileMock.mockClear();
fswCtx.writeFileMock.mockImplementation((...args) => fswCtx.realWrite!(...args));
});
afterEach(async () => {
await tmpRepo.cleanup();
});
it.each(['EROFS', 'EACCES', 'EPERM'] as const)(
'tolerates %s on .git/info/exclude write and logs a warn',
async (code) => {
const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore');
await fs.mkdir(path.dirname(gitignorePath), { recursive: true });
await fs.writeFile(gitignorePath, '*\n', 'utf-8');
const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude');
await fs.mkdir(path.dirname(excludePath), { recursive: true });
await fs.writeFile(excludePath, '# empty\n', 'utf-8');
const cap = _captureLogger();
fswCtx.writeFileMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code }));
try {
await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow();
expect(fswCtx.writeFileMock).toHaveBeenCalled();
expect(
cap
.records()
.some(
(r) =>
r.level === 40 &&
r.code === code &&
typeof r.path === 'string' &&
samePath(String(r.path), excludePath) &&
String(r.msg ?? '').includes('.git/info/exclude'),
),
).toBe(true);
} finally {
cap.restore();
}
},
);
it.each(['EROFS', 'EACCES', 'EPERM'] as const)(
'tolerates %s on .gitnexus/.gitignore write and logs a warn',
async (code) => {
const cap = _captureLogger();
const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore');
fswCtx.writeFileMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code }));
try {
await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow();
expect(fswCtx.writeFileMock).toHaveBeenCalled();
expect(
cap
.records()
.some(
(r) =>
r.level === 40 &&
r.code === code &&
typeof r.path === 'string' &&
samePath(String(r.path), gitignorePath) &&
String(r.msg ?? '').includes('.gitnexus/.gitignore'),
),
).toBe(true);
} finally {
cap.restore();
}
},
);
});

View file

@ -4,10 +4,11 @@
* Tests: getStoragePath, getStoragePaths, readRegistry, registerRepo, unregisterRepo
* Covers hardening fixes #29 (API key file permissions) and #30 (case-insensitive paths on Windows)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { _captureLogger } from '../../src/core/logger.js';
import {
getStoragePath,
getStoragePaths,
@ -73,6 +74,7 @@ describe('ensureGitNexusIgnored (#1233)', () => {
});
afterEach(async () => {
vi.restoreAllMocks();
await tmpRepo.cleanup();
});
@ -139,6 +141,74 @@ describe('ensureGitNexusIgnored (#1233)', () => {
});
expect(status).toBe('');
});
// ─ Read-only workspace tolerance (#1549) ────────────────────────────
// The documented Docker workflow mounts the host workspace at /workspace:ro
// and runs `gitnexus index /workspace/<repo>`. The host has already created
// the .gitnexus dir during a prior `analyze`, so the gitignore file already
// exists with the correct content — there's no real work to do. The tests
// below pin two pieces of behaviour that make that workflow work:
// (a) the function short-circuits when the file is already correct
// (no write attempt, no mtime bump);
// (b) when a write *is* needed but the FS is not writable
// (EROFS / EACCES / EPERM), the function logs and continues instead of
// throwing — so the caller's `registerRepo` work stays committed.
it('does not re-write .gitnexus/.gitignore when it already has the desired content', async () => {
await ensureGitNexusIgnored(tmpRepo.dbPath);
const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore');
const before = await fs.stat(gitignorePath);
await new Promise((resolve) => setTimeout(resolve, 25));
await ensureGitNexusIgnored(tmpRepo.dbPath);
const after = await fs.stat(gitignorePath);
expect(after.mtimeMs).toBe(before.mtimeMs);
});
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'does not throw when .gitnexus/.gitignore is already correct and the storage dir is read-only',
async () => {
await ensureGitNexusIgnored(tmpRepo.dbPath);
const storagePath = path.join(tmpRepo.dbPath, '.gitnexus');
await fs.chmod(storagePath, 0o555);
try {
await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow();
} finally {
await fs.chmod(storagePath, 0o755);
}
},
);
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'warns and continues when the storage dir is read-only and the file does not yet exist',
async () => {
const storagePath = path.join(tmpRepo.dbPath, '.gitnexus');
await fs.mkdir(storagePath, { recursive: true });
await fs.chmod(storagePath, 0o555);
const cap = _captureLogger();
try {
await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow();
expect(
cap
.records()
.some(
(r) =>
r.level === 40 &&
(r.code === 'EACCES' || r.code === 'EPERM') &&
String(r.msg ?? '').includes('.gitnexus/.gitignore') &&
String(r.path ?? '').includes('.gitnexus'),
),
).toBe(true);
} finally {
cap.restore();
await fs.chmod(storagePath, 0o755);
}
},
);
});
// ─── readRegistry ────────────────────────────────────────────────────