Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions gitnexus/src/storage/repo-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
};
Expand All @@ -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');
Expand All @@ -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) ───────────────────────
Expand Down
113 changes: 113 additions & 0 deletions gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
},
);
});
72 changes: 71 additions & 1 deletion gitnexus/test/unit/repo-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,6 +74,7 @@ describe('ensureGitNexusIgnored (#1233)', () => {
});

afterEach(async () => {
vi.restoreAllMocks();
await tmpRepo.cleanup();
});

Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down
Loading