mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
Merge branch 'main' into feat/Desktop-app
This commit is contained in:
commit
51170d5bb2
3 changed files with 230 additions and 6 deletions
|
|
@ -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) ───────────────────────
|
||||
|
|
|
|||
113
gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts
Normal file
113
gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue