mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(storage): classify leftover per-branch index slots Operators need a shared enumerator for deleted-branch leftovers before clean --stale or doctor can reclaim or report them. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(storage): reclaim a per-branch slot and empty branches/ Named clean --branch now shares one rm-then-registry helper so the last leftover slot can drop the empty branches directory, and a failed rm still keeps the retryable summary. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): add clean --stale to reclaim leftover branch indexes Operators can drop per-branch slots whose recorded branch is gone without remembering each name, while a git-list failure stays a no-op. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(cli): report leftover branch indexes in doctor Operators can see cwd orphaned per-branch slots and their size, then reclaim them with clean --stale, without doctor deleting anything. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): keep unreadable branch slots out of stale reclaim A stat error other than ENOENT/ENOTDIR must not look like a missing directory, or clean --stale --force drops the registry row and leaves the slot on disk. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(cli): keep leftover-slot display helpers in the CLI layer Preview and doctor share one size formatter and an i18n path for registry-only rows, so storage no longer owns display copy. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): keep leftover reclaim moving after a registry drop failure Catch removeBranchIndex rejections so --stale continues, match doctor registry rows through canonicalizePath, and size leftover slots sequentially. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): match leftover-slot registry rows with canonicalizePath Use the repo-manager path contract so clean --stale and --branch still see registry-only leftover rows when cwd and the stored path differ. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): contain leftover-slot deletes and re-check live heads Refuse symlink and junction escapes under branches/, unlink slot links instead of removing through them, and skip --stale --force when a name is a local head again. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3338) - Bound listLocalHeads spawnSync with GIT_PATH_LIST_MAX_BUFFER. - Clarify that doctor leftover reporting is cwd-only, not registry-wide. - Drop the MCP/serve assumption from clean --stale delete failures. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3338) - Describe disk-only leftover slots in --stale help, not only recorded branches. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): stop doctor reclaim copy when heads cannot be listed Doctor was naming clean --stale for leftover rows even when git cannot list local heads, which is a no-op. Print the retry-git message instead (#3337). Co-authored-by: Cursor <cursoragent@cursor.com> * revert: drop Unreleased changelog notes from this branch Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): keep live branch pins when a tag shares the name %(refname:short) disambiguates against tags, so clean --stale treated still-local heads as leftover. Fail closed on obstructed slots and unlistable branches/ directories. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3338) Bound leftover-slot listing, revalidate paths immediately before delete, and keep registry rows when a stray disk-only directory claims a recorded branch. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
9.1 KiB
TypeScript
226 lines
9.1 KiB
TypeScript
/**
|
|
* rm-failure paths for adoptFlatBranchLabel (#2364 review F4).
|
|
* Separate from repo-manager.test.ts: Vitest cannot vi.spyOn ESM namespace
|
|
* exports of fs/promises; a delegating vi.mock is required for mock rejects
|
|
* (same split as repo-manager-ensure-ignore-readonly.test.ts, #1549).
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import path from 'path';
|
|
|
|
const fsCtx = vi.hoisted(() => ({
|
|
rmMock: vi.fn(),
|
|
realRm: 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;
|
|
fsCtx.realRm = d.rm.bind(d);
|
|
fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args));
|
|
return {
|
|
default: new Proxy(d, {
|
|
get(target, prop) {
|
|
if (prop === 'rm') return fsCtx.rmMock;
|
|
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 {
|
|
adoptFlatBranchLabel,
|
|
registerRepo,
|
|
listRegisteredRepos,
|
|
getStoragePaths,
|
|
saveMeta,
|
|
type RepoMeta,
|
|
} from '../../src/storage/repo-manager.js';
|
|
import { cleanCommand } from '../../src/cli/clean.js';
|
|
import { removeBranchSlot } from '../../src/storage/stale-branch-slots.js';
|
|
import { branchSlug } from '../../src/storage/branch-index.js';
|
|
import { initGitRepo, commitAll } from '../helpers/temp-git-repo.js';
|
|
import { _captureLogger } from '../../src/core/logger.js';
|
|
import { createTempDir } from '../helpers/test-db.js';
|
|
|
|
describe('adoptFlatBranchLabel — rm failure keeps the branch summary (#2364 F4)', () => {
|
|
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
|
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
|
let savedGitnexusHome: string | undefined;
|
|
|
|
const metaFor = (branch: string, lastCommit: string): RepoMeta => ({
|
|
repoPath: '',
|
|
lastCommit,
|
|
indexedAt: '2026-07-03T12:00:00.000Z',
|
|
branch,
|
|
stats: { files: 1, nodes: 1 },
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
tmpHome = await createTempDir('gitnexus-rm-failure-home-');
|
|
tmpRepo = await createTempDir('gitnexus-rm-failure-repo-');
|
|
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
|
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
|
fsCtx.rmMock.mockClear();
|
|
fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
|
await tmpHome.cleanup();
|
|
await tmpRepo.cleanup();
|
|
});
|
|
|
|
it('keeps the summary, warns with the errno, and still restamps the label on EBUSY', async () => {
|
|
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
|
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
|
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
|
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
|
|
|
const cap = _captureLogger();
|
|
fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' }));
|
|
try {
|
|
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
|
} finally {
|
|
cap.restore();
|
|
}
|
|
|
|
const [entry] = await listRegisteredRepos();
|
|
// The informational label still restamps…
|
|
expect(entry.branch).toBe('feature/x');
|
|
// …but the summary survives so `clean --branch` can still target the dir…
|
|
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
|
// …which is still on disk.
|
|
await expect(fs.access(path.dirname(metaPath))).resolves.toBeUndefined();
|
|
expect(
|
|
cap
|
|
.records()
|
|
.some(
|
|
(r) =>
|
|
r.level === 40 &&
|
|
r.code === 'EBUSY' &&
|
|
typeof r.path === 'string' &&
|
|
String(r.msg ?? '').includes('clean --branch'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('a later adopt retries the rm and drops the summary once the dir is gone', async () => {
|
|
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
|
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
|
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
|
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
|
|
|
fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' }));
|
|
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
|
// Retry with the real rm restored: cleanup completes.
|
|
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
|
|
|
const [entry] = await listRegisteredRepos();
|
|
expect(entry.branch).toBe('feature/x');
|
|
expect(entry.branches).toBeUndefined();
|
|
await expect(fs.access(path.dirname(metaPath))).rejects.toThrow();
|
|
});
|
|
|
|
it('treats a never-materialized sub-index as gone (summary dropped, idempotent)', async () => {
|
|
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
|
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
|
// No saveMeta for the sub-index: nothing on disk, force:true rm is a no-op.
|
|
|
|
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
|
|
|
const [entry] = await listRegisteredRepos();
|
|
expect(entry.branch).toBe('feature/x');
|
|
expect(entry.branches).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('removeBranchSlot — rm failure keeps the branch summary (#3331)', () => {
|
|
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
|
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
|
let savedGitnexusHome: string | undefined;
|
|
|
|
const metaFor = (branch: string, lastCommit: string): RepoMeta => ({
|
|
repoPath: '',
|
|
lastCommit,
|
|
indexedAt: '2026-07-03T12:00:00.000Z',
|
|
branch,
|
|
stats: { files: 1, nodes: 1 },
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
tmpHome = await createTempDir('gitnexus-stale-rm-failure-home-');
|
|
tmpRepo = await createTempDir('gitnexus-stale-rm-failure-repo-');
|
|
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
|
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
|
fsCtx.rmMock.mockClear();
|
|
fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
|
await tmpHome.cleanup();
|
|
await tmpRepo.cleanup();
|
|
});
|
|
|
|
it('keeps the registry row and does not rmdir branches/ on EBUSY', async () => {
|
|
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
|
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
|
const { storagePath, metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
|
const dir = path.dirname(metaPath);
|
|
await saveMeta(dir, metaFor('feature/x', 'bbb2222'));
|
|
|
|
fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' }));
|
|
const result = await removeBranchSlot({
|
|
repoPath: tmpRepo.dbPath,
|
|
storagePath,
|
|
branch: 'feature/x',
|
|
dir,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.keptRegistry).toBe(true);
|
|
expect(result.emptiedBranchesDir).toBe(false);
|
|
const [entry] = await listRegisteredRepos();
|
|
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
|
await expect(fs.access(dir)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('continues remaining --stale candidates after one rm failure', async () => {
|
|
initGitRepo(tmpRepo.dbPath);
|
|
await fs.writeFile(path.join(tmpRepo.dbPath, 'README.md'), 'hi\n');
|
|
commitAll(tmpRepo.dbPath, 'init');
|
|
const storagePath = path.join(tmpRepo.dbPath, '.gitnexus');
|
|
await saveMeta(storagePath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath });
|
|
await registerRepo(tmpRepo.dbPath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath });
|
|
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
|
await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' });
|
|
const dirX = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
|
const dirY = path.join(storagePath, 'branches', branchSlug('feature/y'));
|
|
await saveMeta(dirX, metaFor('feature/x', 'bbb2222'));
|
|
await saveMeta(dirY, metaFor('feature/y', 'ccc3333'));
|
|
|
|
fsCtx.rmMock.mockImplementation(async (target, options) => {
|
|
if (String(target) === dirX) {
|
|
throw Object.assign(new Error('mock busy'), { code: 'EBUSY' });
|
|
}
|
|
return fsCtx.realRm!(target, options);
|
|
});
|
|
|
|
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpRepo.dbPath);
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
try {
|
|
await cleanCommand({ stale: true, force: true });
|
|
const [entry] = await listRegisteredRepos();
|
|
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
|
await expect(fs.access(dirX)).resolves.toBeUndefined();
|
|
await expect(fs.access(dirY)).rejects.toThrow();
|
|
} finally {
|
|
cwdSpy.mockRestore();
|
|
logSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|