GitNexus/gitnexus/test/unit/clean-command-ownership.test.ts
Gergő Magyar 620fd18a5c
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(cli): reclaim leftover per-branch indexes after branch delete (#3338)
* 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>
2026-09-20 16:03:18 +01:00

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();
});
});