mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +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>
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
/**
|
|
* Regression for PR #3060: ordinary `clean --force` must not trust a
|
|
* registry entry that redirects repository A to repository B's external index.
|
|
*
|
|
* This deliberately drives the real clean command, resolver, registry reader,
|
|
* and filesystem. Guard-only tests cannot catch a future bypass in clean.ts.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { cleanCommand } from '../../src/cli/clean.js';
|
|
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
|
import { initGitRepo } from '../helpers/temp-git-repo.js';
|
|
|
|
describe('cleanCommand external storage ownership', () => {
|
|
let fixture: TestDBHandle;
|
|
let previousGitNexusHome: string | undefined;
|
|
let repoA: string;
|
|
let repoB: string;
|
|
let storageB: string;
|
|
let registryPath: string;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await createTempDir();
|
|
previousGitNexusHome = process.env.GITNEXUS_HOME;
|
|
|
|
const home = path.join(fixture.dbPath, 'home');
|
|
repoA = path.join(fixture.dbPath, 'repo-a');
|
|
repoB = path.join(fixture.dbPath, 'repo-b');
|
|
storageB = path.join(fixture.dbPath, 'external-index-b');
|
|
registryPath = path.join(home, 'registry.json');
|
|
|
|
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(repoA), fs.mkdir(repoB)]);
|
|
initGitRepo(repoA);
|
|
initGitRepo(repoB);
|
|
|
|
await fs.mkdir(storageB);
|
|
const metadata = {
|
|
repoPath: repoB,
|
|
storagePath: storageB,
|
|
lastCommit: 'b-indexed-commit',
|
|
indexedAt: '2026-09-05T00:00:00.000Z',
|
|
};
|
|
await Promise.all([
|
|
fs.writeFile(path.join(storageB, 'gitnexus.json'), JSON.stringify(metadata)),
|
|
fs.writeFile(path.join(storageB, 'meta.json'), JSON.stringify(metadata)),
|
|
fs.writeFile(path.join(storageB, 'ownership-sentinel'), 'must survive\n'),
|
|
]);
|
|
|
|
// Deliberately corrupted registry: repository A names B's valid index.
|
|
await fs.writeFile(
|
|
registryPath,
|
|
JSON.stringify([
|
|
{
|
|
name: 'repo-a',
|
|
path: repoA,
|
|
storagePath: storageB,
|
|
lastCommit: 'a-indexed-commit',
|
|
indexedAt: '2026-09-05T00:00:00.000Z',
|
|
},
|
|
]),
|
|
);
|
|
|
|
process.env.GITNEXUS_HOME = home;
|
|
vi.spyOn(process, 'cwd').mockReturnValue(repoA);
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
if (previousGitNexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = previousGitNexusHome;
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
it('deletes a foreign repository-local .gitnexus on clean --all --force', async () => {
|
|
const localStorage = path.join(repoA, '.gitnexus');
|
|
await fs.mkdir(localStorage, { recursive: true });
|
|
const metadata = {
|
|
repoPath: repoB,
|
|
storagePath: localStorage,
|
|
lastCommit: 'foreign-local-commit',
|
|
indexedAt: '2026-09-05T00:00:00.000Z',
|
|
};
|
|
await fs.writeFile(path.join(localStorage, 'gitnexus.json'), JSON.stringify(metadata));
|
|
await fs.writeFile(path.join(localStorage, 'ownership-sentinel'), 'local-foreign\n');
|
|
await fs.writeFile(
|
|
registryPath,
|
|
JSON.stringify([
|
|
{
|
|
name: 'repo-a',
|
|
path: repoA,
|
|
storagePath: localStorage,
|
|
lastCommit: 'a-indexed-commit',
|
|
indexedAt: '2026-09-05T00:00:00.000Z',
|
|
},
|
|
]),
|
|
);
|
|
|
|
await cleanCommand({ force: true, all: true });
|
|
|
|
await expect(fs.access(localStorage)).rejects.toBeTruthy();
|
|
expect(JSON.parse(await fs.readFile(registryPath, 'utf-8'))).toEqual([]);
|
|
});
|
|
|
|
it('preserves a foreign external index and registry entry on ordinary clean --force', async () => {
|
|
await cleanCommand({ force: true });
|
|
|
|
await expect(fs.access(storageB)).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(storageB, 'gitnexus.json'))).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(storageB, 'meta.json'))).resolves.toBeUndefined();
|
|
await expect(fs.readFile(path.join(storageB, 'ownership-sentinel'), 'utf-8')).resolves.toBe(
|
|
'must survive\n',
|
|
);
|
|
|
|
const [remainingEntry] = JSON.parse(await fs.readFile(registryPath, 'utf-8'));
|
|
expect(remainingEntry).toMatchObject({ path: repoA, storagePath: storageB });
|
|
});
|
|
});
|
|
|
|
describe('cleanCommand named branch empty-dir cleanup (#3331)', () => {
|
|
let fixture: TestDBHandle;
|
|
let previousGitNexusHome: string | undefined;
|
|
let repo: string;
|
|
let storagePath: string;
|
|
|
|
beforeEach(async () => {
|
|
fixture = await createTempDir();
|
|
previousGitNexusHome = process.env.GITNEXUS_HOME;
|
|
const home = path.join(fixture.dbPath, 'home');
|
|
repo = path.join(fixture.dbPath, 'repo');
|
|
storagePath = path.join(repo, '.gitnexus');
|
|
await fs.mkdir(home, { recursive: true });
|
|
await fs.mkdir(repo, { recursive: true });
|
|
initGitRepo(repo);
|
|
process.env.GITNEXUS_HOME = home;
|
|
|
|
const { registerRepo, getStoragePaths, saveMeta } =
|
|
await import('../../src/storage/repo-manager.js');
|
|
const meta = {
|
|
repoPath: repo,
|
|
lastCommit: 'aaa',
|
|
indexedAt: '2026-09-20T00:00:00.000Z',
|
|
branch: 'main',
|
|
stats: { files: 1, nodes: 1 },
|
|
};
|
|
await saveMeta(storagePath, meta);
|
|
await registerRepo(repo, meta);
|
|
await registerRepo(
|
|
repo,
|
|
{ ...meta, branch: 'feature/x', lastCommit: 'bbb' },
|
|
{
|
|
branch: 'feature/x',
|
|
},
|
|
);
|
|
const branchDir = path.dirname(getStoragePaths(repo, 'feature/x', storagePath).metaPath);
|
|
await saveMeta(branchDir, { ...meta, branch: 'feature/x' });
|
|
expect(path.basename(path.dirname(branchDir))).toBe('branches');
|
|
|
|
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
if (previousGitNexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = previousGitNexusHome;
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
it('named --branch --force rmdirs empty branches/ and keeps the workspace slot', async () => {
|
|
await cleanCommand({ branch: 'feature/x', force: true });
|
|
await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toBeTruthy();
|
|
await expect(fs.access(path.join(storagePath, 'gitnexus.json'))).resolves.toBeUndefined();
|
|
});
|
|
});
|