mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
feat(clean): remove only what no shared-store member references (#3352)
Deleting a shared checkout slot (clean, clean --all, remove, and the server delete route) recounts references under the store's publish lock and deletes commit graphs no member points at, then the store itself once empty. A graph that cannot be deleted (open on Windows) is reported and kept for the next pass. clean --gc also drops member slots whose worktree is gone or no longer resolves to the store. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
22ad5e312d
commit
8c74a86fc3
9 changed files with 450 additions and 21 deletions
|
|
@ -33,6 +33,13 @@ import {
|
|||
listParkedLbugSidecars,
|
||||
} from '../core/lbug/sidecar-recovery.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { getGlobalDir } from '../storage/global-dir.js';
|
||||
import { STORES_DIR } from '../storage/shared-store.js';
|
||||
import {
|
||||
reclaimAfterSlotRemoval,
|
||||
reclaimSharedStore,
|
||||
type ReclaimResult,
|
||||
} from '../storage/shared-store-lifecycle.js';
|
||||
|
||||
type OwnedCwdStorage = {
|
||||
repo: NonNullable<Awaited<ReturnType<typeof findRepo>>>;
|
||||
|
|
@ -147,13 +154,50 @@ const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
|
|||
}
|
||||
};
|
||||
|
||||
const reportReclaim = (result: ReclaimResult | null): void => {
|
||||
if (!result) return;
|
||||
if (result.removed.length > 0) {
|
||||
console.log(t('clean.shared.reclaimed', { count: result.removed.length }));
|
||||
}
|
||||
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
|
||||
};
|
||||
|
||||
/** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */
|
||||
const collectSharedStores = async (): Promise<void> => {
|
||||
const storesDir = path.join(getGlobalDir(), STORES_DIR);
|
||||
const names = await fs.readdir(storesDir).catch(() => [] as string[]);
|
||||
if (names.length === 0) {
|
||||
console.log(t('clean.gc.none'));
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
const root = path.join(storesDir, name);
|
||||
const result = await reclaimSharedStore(root, { gc: true });
|
||||
console.log(
|
||||
t('clean.gc.store', {
|
||||
path: root,
|
||||
members: result.droppedMembers.length,
|
||||
graphs: result.removed.length,
|
||||
}),
|
||||
);
|
||||
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
|
||||
if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root }));
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanCommand = async (options?: {
|
||||
force?: boolean;
|
||||
all?: boolean;
|
||||
lbugSidecars?: boolean;
|
||||
stale?: boolean;
|
||||
branch?: string;
|
||||
gc?: boolean;
|
||||
}) => {
|
||||
if (options?.gc) {
|
||||
await collectSharedStores();
|
||||
return;
|
||||
}
|
||||
|
||||
// --stale: reclaim leftover per-branch slots whose recorded branch is not
|
||||
// a live local head (#3331). Exclusive arm before --branch.
|
||||
if (options?.stale) {
|
||||
|
|
@ -295,6 +339,7 @@ export const cleanCommand = async (options?: {
|
|||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
console.log(t('clean.deletedRepo', { name: entry.name, storagePath }));
|
||||
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
|
||||
|
|
@ -341,6 +386,7 @@ export const cleanCommand = async (options?: {
|
|||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(repo.repoPath);
|
||||
console.log(t('common.deleted', { target: storagePath }));
|
||||
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete:');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ export const en = {
|
|||
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
|
||||
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
|
||||
'clean.notFoundHere': 'No indexed repository found in this directory.',
|
||||
'clean.shared.reclaimed':
|
||||
'Shared store: removed {{count}} commit graph(s) no checkout references.',
|
||||
'clean.shared.kept':
|
||||
'Shared store: kept {{count}} unreferenced commit graph(s) that are still open; run `gitnexus clean --gc` later.',
|
||||
'clean.shared.storeRemoved': 'Shared store: removed {{path}} (no checkouts remain).',
|
||||
'clean.gc.none': 'No shared stores to collect.',
|
||||
'clean.gc.store':
|
||||
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
|
||||
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
|
||||
'clean.branchNotIndexed':
|
||||
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export const zhCN = {
|
|||
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
|
||||
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
|
||||
'clean.notFoundHere': '当前目录未找到已索引仓库。',
|
||||
'clean.shared.reclaimed': '共享存储:已删除 {{count}} 个不再被任何检出引用的提交图。',
|
||||
'clean.shared.kept':
|
||||
'共享存储:保留了 {{count}} 个仍处于打开状态的未引用提交图;请稍后运行 `gitnexus clean --gc`。',
|
||||
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
|
||||
'clean.gc.none': '没有可回收的共享存储。',
|
||||
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
|
||||
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
|
||||
'clean.branchNotIndexed':
|
||||
'该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',
|
||||
|
|
|
|||
|
|
@ -350,6 +350,10 @@ program
|
|||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
|
||||
.option('--stale', 'Reclaim leftover branch indexes that are not a live local head')
|
||||
.option(
|
||||
'--gc',
|
||||
'Drop shared-store checkouts whose worktree is gone and delete commit graphs nothing references',
|
||||
)
|
||||
.option(
|
||||
'--lbug-sidecars',
|
||||
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
* here there is no pipeline, so no conflation.)
|
||||
*/
|
||||
|
||||
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
|
|
@ -100,6 +101,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
|||
try {
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
await reclaimAfterSlotRemoval(storagePath);
|
||||
console.log(t('remove.removed', { name: entry.name }));
|
||||
console.log(` ${t('common.path')}: ${entry.path}`);
|
||||
console.log(` ${t('common.storage')}: ${entry.storagePath}`);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import { createHash, randomUUID } from 'crypto';
|
|||
import { constants as fsConstants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
import { acquireIndexLock } from '../storage/index-lock.js';
|
||||
import { withStoreLock } from '../storage/shared-store-lifecycle.js';
|
||||
import { commitDistanceToHead, isWorkingTreeDirty } from '../storage/git.js';
|
||||
import { registerRepo, saveMeta } from '../storage/repo-manager.js';
|
||||
import { loadMeta, type RepoMeta } from '../storage/repo-meta.js';
|
||||
|
|
@ -250,26 +251,6 @@ export const ensurePrivateSharedGraph = async (slot: string, log: Log): Promise<
|
|||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize one kind of store-wide write (`publish`, `cache`) across
|
||||
* checkouts. Each checkout's own slot is already covered by its index lock.
|
||||
*/
|
||||
export const withStoreLock = async <T>(
|
||||
layout: SharedStoreLayout,
|
||||
name: 'publish' | 'cache',
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const lockDir = path.join(layout.root, 'locks', name);
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
const lock = await acquireIndexLock(lockDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store ${name} lock at ${lockDir}.`);
|
||||
return await fn();
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Every directory in the store whose metadata may record parse-cache keys:
|
||||
* each checkout slot (its branch slots are read by the caller's per-root
|
||||
|
|
@ -350,3 +331,5 @@ export const publishSharedGraph = async (
|
|||
// must still end up registered at its slot.
|
||||
await registerRepo(repoPath, meta, { storagePath: slot });
|
||||
};
|
||||
|
||||
export { withStoreLock };
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* CORS is restricted to localhost, private/LAN networks, and the deployed site.
|
||||
*/
|
||||
|
||||
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
|
|
@ -1318,6 +1319,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
// 1. Delete the .gitnexus index/storage directory
|
||||
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
|
||||
await reclaimAfterSlotRemoval(storagePath);
|
||||
|
||||
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/.
|
||||
// getCloneDir now throws on names that are not filesystem-safe (e.g.
|
||||
|
|
|
|||
157
gitnexus/src/storage/shared-store-lifecycle.ts
Normal file
157
gitnexus/src/storage/shared-store-lifecycle.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Store-wide locking and reference-counted cleanup for the shared sibling
|
||||
* store (#3352).
|
||||
*
|
||||
* A commit graph is live while any member checkout slot's metadata records
|
||||
* it as `graphPath`. `reclaimSharedStore` deletes every commit graph with no
|
||||
* reference, and the whole store once no member and no commit graph remain.
|
||||
* It runs under the store's publish lock, so a concurrent analyze that is
|
||||
* publishing or pointing at a graph is never raced.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from './index-lock.js';
|
||||
import { loadMeta } from './repo-meta.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
storeRootOfCheckoutSlot,
|
||||
type SharedStoreLayout,
|
||||
} from './shared-store.js';
|
||||
import { LBUG_DIRECTORY } from './storage-constants.js';
|
||||
|
||||
type StoreRoot = Pick<SharedStoreLayout, 'root'>;
|
||||
|
||||
/**
|
||||
* Serialize one kind of store-wide write (`publish`, `cache`) across
|
||||
* checkouts. Each checkout's own slot is already covered by its index lock.
|
||||
*/
|
||||
export const withStoreLock = async <T>(
|
||||
layout: StoreRoot,
|
||||
name: 'publish' | 'cache',
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const lockDir = path.join(layout.root, 'locks', name);
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
const lock = await acquireIndexLock(lockDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store ${name} lock at ${lockDir}.`);
|
||||
return await fn();
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
|
||||
export interface ReclaimResult {
|
||||
/** Commit graph directories deleted. */
|
||||
removed: string[];
|
||||
/** Unreferenced commit graphs that could not be deleted (for example, open on Windows). */
|
||||
kept: string[];
|
||||
/** Member slots dropped by garbage collection. */
|
||||
droppedMembers: string[];
|
||||
/** The store root was deleted because nothing remained. */
|
||||
storeRemoved: boolean;
|
||||
}
|
||||
|
||||
const listDir = (dir: string): Promise<string[]> => fs.readdir(dir).catch(() => [] as string[]);
|
||||
|
||||
/**
|
||||
* A member slot whose checkout is gone or no longer resolves to this store
|
||||
* (the worktree was deleted, or sharing was turned off for it).
|
||||
*/
|
||||
const isOrphanMember = async (slot: string, storeRoot: string): Promise<boolean> => {
|
||||
const meta = await loadMeta(slot);
|
||||
if (!meta?.repoPath) return false; // unknown slot: never collect what we cannot attribute
|
||||
if (!existsSync(meta.repoPath)) return true;
|
||||
const layout = resolveSharedStore(meta.repoPath);
|
||||
return !layout || layout.root !== storeRoot || layout.checkoutSlot !== slot;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete unreferenced commit graphs, stale publish staging, and — with `gc` —
|
||||
* member slots whose checkout no longer belongs to the store. Removes the
|
||||
* store itself when nothing remains.
|
||||
*/
|
||||
export const reclaimSharedStore = async (
|
||||
storeRoot: string,
|
||||
opts: { gc?: boolean } = {},
|
||||
): Promise<ReclaimResult> => {
|
||||
const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false };
|
||||
if (!existsSync(storeRoot)) return result;
|
||||
const checkoutsDir = path.join(storeRoot, 'checkouts');
|
||||
const commitsDir = path.join(storeRoot, 'commits');
|
||||
|
||||
await withStoreLock({ root: storeRoot }, 'publish', async () => {
|
||||
const referenced = new Set<string>();
|
||||
let slots = (await listDir(checkoutsDir)).map((name) => path.join(checkoutsDir, name));
|
||||
if (opts.gc) {
|
||||
const live: string[] = [];
|
||||
for (const slot of slots) {
|
||||
if (await isOrphanMember(slot, storeRoot)) {
|
||||
await fs.rm(slot, { recursive: true, force: true });
|
||||
result.droppedMembers.push(slot);
|
||||
} else {
|
||||
live.push(slot);
|
||||
}
|
||||
}
|
||||
slots = live;
|
||||
}
|
||||
for (const slot of slots) {
|
||||
const graphPath = (await loadMeta(slot))?.graphPath;
|
||||
if (graphPath) referenced.add(path.dirname(path.resolve(graphPath)));
|
||||
}
|
||||
|
||||
for (const name of await listDir(commitsDir)) {
|
||||
const dir = path.join(commitsDir, name);
|
||||
if (referenced.has(dir)) continue;
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
if (!name.startsWith('.')) result.removed.push(dir);
|
||||
} catch {
|
||||
// Windows refuses to delete a file another process has open (an MCP
|
||||
// reader). Keep it for the next reclaim instead of failing the clean.
|
||||
if (!name.startsWith('.')) result.kept.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = (await listDir(checkoutsDir)).length + (await listDir(commitsDir)).length;
|
||||
if (remaining === 0) {
|
||||
// The lock directory lives inside the store; removing it while held is
|
||||
// safe on POSIX and is retried on the next reclaim elsewhere.
|
||||
await fs
|
||||
.rm(storeRoot, { recursive: true, force: true })
|
||||
.then(() => {
|
||||
result.storeRemoved = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* After a storage slot was deleted: reclaim its store when it was a shared
|
||||
* checkout slot. No-op for any other storage path. Never throws — the slot
|
||||
* deletion already succeeded and reclaim is retried by the next clean.
|
||||
*/
|
||||
export const reclaimAfterSlotRemoval = async (
|
||||
storagePath: string,
|
||||
): Promise<ReclaimResult | null> => {
|
||||
const storeRoot = storeRootOfCheckoutSlot(storagePath);
|
||||
if (!storeRoot) return null;
|
||||
try {
|
||||
return await reclaimSharedStore(storeRoot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Graph directory a checkout slot reads, for reporting. */
|
||||
export const describeSharedGraph = (
|
||||
graphPath: string,
|
||||
storagePath: string,
|
||||
): 'shared' | 'private' =>
|
||||
path.resolve(graphPath) === path.join(path.resolve(storagePath), LBUG_DIRECTORY)
|
||||
? 'private'
|
||||
: 'shared';
|
||||
221
gitnexus/test/integration/shared-store-clean.test.ts
Normal file
221
gitnexus/test/integration/shared-store-clean.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
resolveSharedStore,
|
||||
sharedStoreLayout,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import {
|
||||
reclaimAfterSlotRemoval,
|
||||
reclaimSharedStore,
|
||||
} from '../../src/storage/shared-store-lifecycle.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #3352 U6 — clean removes only what no remaining member references.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const commitDirs = async (layout: SharedStoreLayout): Promise<string[]> =>
|
||||
(await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter(
|
||||
(n) => !n.startsWith('.'),
|
||||
);
|
||||
|
||||
describe('shared store clean (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let savedCwd: string;
|
||||
let main: string;
|
||||
let wtA: string;
|
||||
let wtB: string;
|
||||
|
||||
const analyze = async (checkout: string) => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
return runFullAnalysis(checkout, {}, { onProgress: () => {} });
|
||||
};
|
||||
|
||||
const cleanIn = async (checkout: string, options: Record<string, unknown>) => {
|
||||
const { cleanCommand } = await import('../../src/cli/clean.js');
|
||||
process.chdir(checkout);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await cleanCommand(options);
|
||||
return log.mock.calls.map((c) => String(c[0]));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
savedCwd = process.cwd();
|
||||
tmpHome = await createTempDir('gitnexus-clean-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-clean-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
|
||||
commitAll(main, 'init');
|
||||
wtA = path.join(root, 'wt-a');
|
||||
wtB = path.join(root, 'wt-b');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(savedCwd);
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers AE5: cleaning one of two worktrees at X keeps X for the other', async () => {
|
||||
await analyze(wtA);
|
||||
await analyze(wtB);
|
||||
const graph = getStoragePaths(wtB, undefined, layoutOf(wtB).checkoutSlot).lbugPath;
|
||||
|
||||
await cleanIn(wtA, { force: true });
|
||||
|
||||
expect(existsSync(layoutOf(wtA).checkoutSlot)).toBe(false);
|
||||
expect(existsSync(graph)).toBe(true);
|
||||
expect((await loadMeta(layoutOf(wtB).checkoutSlot))?.graphPath).toBe(graph);
|
||||
}, 240_000);
|
||||
|
||||
it('deletes the commit graph and the store when the last member is cleaned', async () => {
|
||||
await analyze(wtA);
|
||||
await analyze(wtB);
|
||||
const layout = layoutOf(wtA);
|
||||
|
||||
await cleanIn(wtA, { force: true });
|
||||
const logs = await cleanIn(wtB, { force: true });
|
||||
|
||||
expect(await commitDirs(layout)).toEqual([]);
|
||||
expect(existsSync(layout.root)).toBe(false);
|
||||
expect(logs.join('\n')).toMatch(/removed 1 commit graph/);
|
||||
}, 240_000);
|
||||
|
||||
it('previews without --force and deletes nothing', async () => {
|
||||
await analyze(wtA);
|
||||
const layout = layoutOf(wtA);
|
||||
await cleanIn(wtA, {});
|
||||
expect(existsSync(layout.checkoutSlot)).toBe(true);
|
||||
expect(await commitDirs(layout)).toHaveLength(1);
|
||||
}, 240_000);
|
||||
|
||||
it('clean --gc drops a deleted worktree and the graph only it referenced', async () => {
|
||||
await analyze(wtA);
|
||||
await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n');
|
||||
commitAll(wtB, 'b');
|
||||
await analyze(wtB);
|
||||
const layout = layoutOf(wtB);
|
||||
const slotB = layout.checkoutSlot;
|
||||
expect(await commitDirs(layout)).toHaveLength(2);
|
||||
|
||||
git(main, 'worktree', 'remove', '--force', wtB);
|
||||
const logs = await cleanIn(main, { gc: true });
|
||||
|
||||
expect(existsSync(slotB)).toBe(false);
|
||||
expect(await commitDirs(layout)).toHaveLength(1);
|
||||
expect(existsSync(layoutOf(wtA).checkoutSlot)).toBe(true);
|
||||
expect(logs.join('\n')).toMatch(/dropped 1 checkout\(s\), removed 1 commit graph/);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
describe('reclaimSharedStore', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-reclaim-home-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
const layout = (): SharedStoreLayout => sharedStoreLayout('repo-0123456789ab', '/tmp/wt');
|
||||
|
||||
const commitGraph = async (name: string): Promise<string> => {
|
||||
const dir = path.join(layout().commitsDir, name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'lbug'), 'graph');
|
||||
return dir;
|
||||
};
|
||||
|
||||
const member = async (slotName: string, meta: Record<string, unknown>): Promise<string> => {
|
||||
const slot = path.join(layout().checkoutsDir, slotName);
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await saveMeta(slot, { lastCommit: '', indexedAt: '', repoPath: '/tmp/wt', ...meta });
|
||||
return slot;
|
||||
};
|
||||
|
||||
it('keeps referenced graphs and removes unreferenced graphs and stale staging', async () => {
|
||||
const kept = await commitGraph('aaaaaaa-1111111111111111');
|
||||
const orphan = await commitGraph('bbbbbbb-2222222222222222');
|
||||
const staging = await commitGraph('.publish-dead');
|
||||
await member('wt-000000000000', { graphPath: path.join(kept, 'lbug') });
|
||||
|
||||
const result = await reclaimSharedStore(layout().root);
|
||||
|
||||
expect(existsSync(kept)).toBe(true);
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
expect(existsSync(staging)).toBe(false);
|
||||
expect(result.removed).toEqual([orphan]);
|
||||
expect(result.storeRemoved).toBe(false);
|
||||
});
|
||||
|
||||
it('never collects a slot whose metadata it cannot attribute', async () => {
|
||||
const slot = path.join(layout().checkoutsDir, 'unknown-000000000000');
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
const result = await reclaimSharedStore(layout().root, { gc: true });
|
||||
expect(existsSync(slot)).toBe(true);
|
||||
expect(result.droppedMembers).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a no-op for storage outside the stores directory', async () => {
|
||||
const outside = path.join(tmpHome.dbPath, 'elsewhere', '.gitnexus');
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
expect(await reclaimAfterSlotRemoval(outside)).toBeNull();
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a graph it cannot delete instead of failing', async () => {
|
||||
const orphan = await commitGraph('ccccccc-3333333333333333');
|
||||
await member('wt-000000000000', {});
|
||||
const rm = vi.spyOn(fs, 'rm').mockImplementation(async (target) => {
|
||||
if (String(target) === orphan) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
|
||||
});
|
||||
try {
|
||||
const result = await reclaimSharedStore(layout().root);
|
||||
expect(result.kept).toEqual([orphan]);
|
||||
expect(result.removed).toEqual([]);
|
||||
} finally {
|
||||
rm.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue