mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-08 08:07:08 +00:00
469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
/**
|
|
* Status Command
|
|
*
|
|
* Shows the indexing status of the current repository.
|
|
*/
|
|
|
|
import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js';
|
|
import {
|
|
describeSharedGraph,
|
|
findLegacyLocalIndex,
|
|
readGraphCloneKind,
|
|
} from '../storage/shared-store-lifecycle.js';
|
|
import { formatSlotSize } from './stale-branch-format.js';
|
|
import path from 'path';
|
|
import {
|
|
getStoragePaths,
|
|
loadMeta,
|
|
hasKuzuIndex,
|
|
readRegistryStrict,
|
|
resolveRegistryEntry,
|
|
RegistryNotFoundError,
|
|
RegistryAmbiguousTargetError,
|
|
} from '../storage/repo-manager.js';
|
|
import {
|
|
requireRegisteredStoragePath,
|
|
requireStoragePath,
|
|
STATUS_STORAGE_REQUIREMENTS,
|
|
StorageRequirementError,
|
|
isUnusableIndexInspection,
|
|
} from '../storage/storage-resolver.js';
|
|
import {
|
|
getCurrentCommit,
|
|
getCurrentBranch,
|
|
isGitRepo,
|
|
getGitRoot,
|
|
isWorkingTreeDirty,
|
|
} from '../storage/git.js';
|
|
import {
|
|
analyzerRunnerIdentitiesEqual,
|
|
resolveAnalyzerRunnerIdentity,
|
|
} from '../core/analyzer-identity.js';
|
|
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
|
import { getFtsDisabledReason, FTS_DISABLED_MESSAGE } from '../core/search/fts-policy.js';
|
|
import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js';
|
|
import {
|
|
checkoutIsDirectory,
|
|
contentRetentionFromMeta,
|
|
isFullSourceAvailable,
|
|
} from '../core/content-retention.js';
|
|
import { t } from './i18n/index.js';
|
|
import { formatPathForTerminal as formatDriftPath } from './format-path.js';
|
|
|
|
/** How many drifted paths the report names before summarizing the rest. */
|
|
const DRIFT_SAMPLE_LIMIT = 10;
|
|
|
|
/**
|
|
* Machine-readable form of the per-file comparison. `'not-checked'` is its own
|
|
* value rather than a silent omission: it says the index was already stale on
|
|
* metadata alone, so the scan was skipped, which is not the same claim as a
|
|
* scan that ran and found nothing.
|
|
*/
|
|
const describeContentDrift = (drift: IndexContentDrift | undefined) => {
|
|
if (!drift) return { status: 'not-checked' as const };
|
|
if (drift.kind === 'current') {
|
|
return { status: 'current' as const, coveredFiles: drift.coveredFileCount };
|
|
}
|
|
if (drift.kind === 'unmeasurable') {
|
|
return { status: 'unmeasurable' as const, reason: drift.reason };
|
|
}
|
|
return {
|
|
status: 'drifted' as const,
|
|
counts: {
|
|
changed: drift.changed.length,
|
|
added: drift.added.length,
|
|
deleted: drift.deleted.length,
|
|
},
|
|
changed: drift.changed.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
added: drift.added.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
deleted: drift.deleted.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
truncated: {
|
|
changed: drift.changed.length > DRIFT_SAMPLE_LIMIT,
|
|
added: drift.added.length > DRIFT_SAMPLE_LIMIT,
|
|
deleted: drift.deleted.length > DRIFT_SAMPLE_LIMIT,
|
|
},
|
|
};
|
|
};
|
|
|
|
const printDriftDetail = (drift: Extract<IndexContentDrift, { kind: 'drifted' }>): void => {
|
|
console.log(
|
|
t('status.indexContentDrifted', {
|
|
changed: drift.changed.length,
|
|
added: drift.added.length,
|
|
deleted: drift.deleted.length,
|
|
}),
|
|
);
|
|
const labelled: [string, readonly string[]][] = [
|
|
[t('status.driftChanged'), drift.changed],
|
|
[t('status.driftAdded'), drift.added],
|
|
[t('status.driftDeleted'), drift.deleted],
|
|
];
|
|
for (const [label, paths] of labelled) {
|
|
for (const p of paths.slice(0, DRIFT_SAMPLE_LIMIT)) {
|
|
console.log(` ${label}: ${formatDriftPath(p)}`);
|
|
}
|
|
const remaining = paths.length - DRIFT_SAMPLE_LIMIT;
|
|
if (remaining > 0) console.log(t('status.indexContentMore', { count: remaining, label }));
|
|
}
|
|
};
|
|
|
|
const isExpectedUnindexedStatus = (error: StorageRequirementError): boolean =>
|
|
isUnusableIndexInspection(error.inspection);
|
|
|
|
const printNotIndexed = (repoPath: string, storagePath: string, json: boolean): void => {
|
|
if (json) {
|
|
console.log(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
repository: repoPath,
|
|
storagePath,
|
|
error: 'not-indexed',
|
|
}),
|
|
);
|
|
} else {
|
|
console.log(t('status.repoNotIndexed'));
|
|
console.log(t('common.runAnalyzeShort'));
|
|
}
|
|
};
|
|
|
|
export interface StatusOptions {
|
|
json?: boolean;
|
|
/** Resolve a registered index without requiring its original checkout to remain on disk. */
|
|
repo?: string;
|
|
}
|
|
|
|
export const statusCommand = async (options: StatusOptions = {}) => {
|
|
if (options.repo) {
|
|
let entry;
|
|
try {
|
|
entry = resolveRegistryEntry(await readRegistryStrict(), options.repo);
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
if (err instanceof RegistryNotFoundError || err instanceof RegistryAmbiguousTargetError) {
|
|
if (options.json) {
|
|
console.log(
|
|
JSON.stringify({ schemaVersion: 1, repository: options.repo, error: 'not-indexed' }),
|
|
);
|
|
} else {
|
|
console.log(error);
|
|
}
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
let storagePath: string;
|
|
try {
|
|
storagePath = await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS);
|
|
} catch (err) {
|
|
if (!(err instanceof StorageRequirementError) || !isExpectedUnindexedStatus(err)) throw err;
|
|
if (err.inspection.state === 'owned' && !err.inspection.hasCodeIndexDB) {
|
|
const staleKuzu = await hasKuzuIndex(err.inspection.storagePath);
|
|
if (options.json) {
|
|
console.log(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
repository: entry.path,
|
|
storagePath: err.inspection.storagePath,
|
|
error: staleKuzu ? 'stale-kuzu-index' : 'not-indexed',
|
|
}),
|
|
);
|
|
} else if (staleKuzu) {
|
|
console.log(t('status.staleKuzu'));
|
|
console.log(t('status.rebuildLadybug'));
|
|
} else {
|
|
console.log(`No usable code index at ${err.inspection.storagePath}`);
|
|
}
|
|
} else {
|
|
printNotIndexed(entry.path, err.inspection.storagePath, Boolean(options.json));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const meta = await loadMeta(storagePath);
|
|
if (!meta) {
|
|
if (options.json) {
|
|
console.log(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
repository: entry.path,
|
|
storagePath,
|
|
error: 'not-indexed',
|
|
}),
|
|
);
|
|
} else {
|
|
console.log(`No readable index metadata at ${storagePath}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
|
|
const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(
|
|
meta.runnerIdentity,
|
|
currentRunnerIdentity,
|
|
);
|
|
const incompleteReasons = getIndexIncompleteReasons(meta);
|
|
const sourceAvailable = isFullSourceAvailable(
|
|
contentRetentionFromMeta(meta),
|
|
await checkoutIsDirectory(entry.path),
|
|
);
|
|
const payload = {
|
|
schemaVersion: 1,
|
|
repository: entry.path,
|
|
storagePath,
|
|
sourceAvailable,
|
|
index: {
|
|
indexedAt: meta.indexedAt,
|
|
commit: meta.lastCommit,
|
|
runnerIdentity: meta.runnerIdentity ?? null,
|
|
runnerIdentityStatus: runnerIdentityIsCurrent ? 'current' : 'stale-or-unknown',
|
|
incompleteReasons,
|
|
contentRetention: meta.contentRetention ?? 'full',
|
|
},
|
|
current: sourceAvailable ? { commit: getCurrentCommit(entry.path) } : null,
|
|
// Without a checkout GitNexus can prove the index is readable, but cannot
|
|
// certify that it is current relative to source. Keep that distinction in
|
|
// the machine-readable status instead of reporting a false all-clear.
|
|
status: sourceAvailable ? 'registered' : 'source-unavailable',
|
|
};
|
|
if (options.json) {
|
|
console.log(JSON.stringify(payload));
|
|
} else {
|
|
console.log(`Repository: ${entry.path}`);
|
|
console.log(`Index storage: ${storagePath}`);
|
|
console.log(`Indexed: ${new Date(meta.indexedAt).toLocaleString()}`);
|
|
console.log(`Indexed commit: ${meta.lastCommit?.slice(0, 7)}`);
|
|
console.log(
|
|
sourceAvailable
|
|
? 'Status: registered index (use status without --repo for working-tree freshness)'
|
|
: 'Status: source checkout unavailable; graph index remains queryable through the registry',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const cwd = process.cwd();
|
|
|
|
if (!isGitRepo(cwd)) {
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ schemaVersion: 1, error: 'not-git-repository' }));
|
|
return;
|
|
}
|
|
console.log(t('status.notGitRepo'));
|
|
return;
|
|
}
|
|
|
|
const repoPath = getGitRoot(cwd);
|
|
if (!repoPath) {
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ schemaVersion: 1, error: 'not-git-repository' }));
|
|
return;
|
|
}
|
|
console.log(t('status.notGitRepo'));
|
|
return;
|
|
}
|
|
|
|
let storagePath: string;
|
|
try {
|
|
storagePath = await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS);
|
|
} catch (err) {
|
|
if (!(err instanceof StorageRequirementError) || !isExpectedUnindexedStatus(err)) throw err;
|
|
const inspection = err.inspection;
|
|
const staleKuzu = await hasKuzuIndex(inspection.storagePath);
|
|
if (options.json) {
|
|
console.log(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
repository: repoPath,
|
|
storagePath: inspection.storagePath,
|
|
error: staleKuzu ? 'stale-kuzu-index' : 'not-indexed',
|
|
}),
|
|
);
|
|
} else if (staleKuzu) {
|
|
console.log(t('status.staleKuzu'));
|
|
console.log(t('status.rebuildLadybug'));
|
|
} else {
|
|
console.log(t('status.repoNotIndexed'));
|
|
console.log(t('common.runAnalyzeShort'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const meta = await loadMeta(storagePath);
|
|
if (!meta) {
|
|
printNotIndexed(repoPath, storagePath, Boolean(options.json));
|
|
return;
|
|
}
|
|
|
|
const repo = {
|
|
repoPath,
|
|
storagePath,
|
|
meta,
|
|
};
|
|
|
|
const currentCommit = getCurrentCommit(repo.repoPath);
|
|
const currentBranch = getCurrentBranch(repo.repoPath);
|
|
|
|
// Pick the index matching the checked-out branch (#2106/#2354). A pinned
|
|
// `--branch` sub-index for the current branch wins; otherwise report the
|
|
// flat workspace index, which follows the checked-out working tree — the
|
|
// commit comparison below then says whether it needs a re-analyze. Legacy/
|
|
// no-branch metas and detached HEAD also fall through to the flat index.
|
|
let activeMeta = repo.meta;
|
|
let workspaceLagsBranch = false;
|
|
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
|
|
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch, repo.storagePath);
|
|
const branchMeta = await loadMeta(path.dirname(metaPath));
|
|
if (branchMeta) activeMeta = branchMeta;
|
|
else workspaceLagsBranch = true;
|
|
}
|
|
|
|
const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
|
|
const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(
|
|
activeMeta.runnerIdentity,
|
|
currentRunnerIdentity,
|
|
);
|
|
const incompleteReasons = getIndexIncompleteReasons(activeMeta);
|
|
const metadataIsCurrent =
|
|
currentCommit === activeMeta.lastCommit &&
|
|
runnerIdentityIsCurrent &&
|
|
incompleteReasons.length === 0;
|
|
|
|
// A matching HEAD is not enough: `analyze` re-indexes changed content at the
|
|
// same commit, so the files the index covers must still be compared against
|
|
// disk. Only worth the scan once the cheap metadata checks agree, and skipped
|
|
// for non-git folders (currentCommit === '') to match analyze.
|
|
const contentDrift: IndexContentDrift | undefined =
|
|
metadataIsCurrent && currentCommit !== ''
|
|
? await detectIndexContentDrift(
|
|
repo.repoPath,
|
|
activeMeta.fileHashes,
|
|
activeMeta.indexCoverage,
|
|
)
|
|
: undefined;
|
|
|
|
// The repo-wide dirty flag survives only as the fallback for metadata written
|
|
// before `fileHashes` existed. Where the per-file comparison can run it
|
|
// decides, so a file the index does not cover no longer pins a byte-current
|
|
// index to a "stale" verdict that `analyze` is powerless to clear (#3077).
|
|
const contentIsCurrent =
|
|
contentDrift === undefined ||
|
|
contentDrift.kind === 'current' ||
|
|
(contentDrift.kind === 'unmeasurable' &&
|
|
contentDrift.reason === 'no-file-hashes' &&
|
|
!isWorkingTreeDirty(repo.repoPath));
|
|
|
|
const isUpToDate = metadataIsCurrent && contentIsCurrent;
|
|
// Shared sibling store (#3352): which graph this checkout reads, and any
|
|
// pre-adoption index still sitting in <repo>/.gitnexus.
|
|
const storeRoot = storeRootOfCheckoutSlot(repo.storagePath);
|
|
const sharedStore = storeRoot
|
|
? {
|
|
key: path.basename(storeRoot),
|
|
// A pinned branch index (`branches/<slug>/lbug`) is always private;
|
|
// only the flat slot can point at a shared commit graph.
|
|
graph:
|
|
activeMeta === repo.meta
|
|
? describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath)
|
|
: ('private' as const),
|
|
commit: activeMeta.lastCommit,
|
|
}
|
|
: null;
|
|
// A private flat graph copied from a shared one: say whether the filesystem
|
|
// shared its unchanged pages (copy-on-write) or it is a full copy.
|
|
const privateClone =
|
|
sharedStore?.graph === 'private' && activeMeta === repo.meta
|
|
? await readGraphCloneKind(repo.storagePath)
|
|
: null;
|
|
const legacyLocalIndex = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
|
|
if (options.json) {
|
|
console.log(
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
repository: repo.repoPath,
|
|
branch: currentBranch,
|
|
workspaceIndexBranch: workspaceLagsBranch ? (repo.meta.branch ?? null) : null,
|
|
index: {
|
|
indexedAt: activeMeta.indexedAt,
|
|
commit: activeMeta.lastCommit,
|
|
...(activeMeta.capabilities ? { capabilities: activeMeta.capabilities } : {}),
|
|
runnerIdentity: activeMeta.runnerIdentity ?? null,
|
|
runnerIdentityStatus: runnerIdentityIsCurrent ? 'current' : 'stale-or-unknown',
|
|
incompleteReasons,
|
|
},
|
|
current: {
|
|
commit: currentCommit,
|
|
runnerIdentity: currentRunnerIdentity,
|
|
},
|
|
contentDrift: describeContentDrift(contentDrift),
|
|
sharedStore: sharedStore ? { ...sharedStore, privateClone } : null,
|
|
legacyLocalIndex: legacyLocalIndex
|
|
? { path: legacyLocalIndex.dir, bytes: legacyLocalIndex.bytes }
|
|
: null,
|
|
status: isUpToDate ? 'up-to-date' : 'stale',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`${t('status.repository')}: ${repo.repoPath}`);
|
|
console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
|
|
|
|
if (workspaceLagsBranch) {
|
|
console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
|
|
}
|
|
|
|
if (sharedStore) {
|
|
console.log(
|
|
sharedStore.graph === 'shared'
|
|
? t('status.sharedStoreShared', {
|
|
key: sharedStore.key,
|
|
commit: sharedStore.commit.slice(0, 7),
|
|
})
|
|
: t('status.sharedStorePrivate', { key: sharedStore.key }),
|
|
);
|
|
if (privateClone) {
|
|
console.log(
|
|
t(
|
|
privateClone === 'copy-on-write'
|
|
? 'status.sharedStoreCloneCow'
|
|
: 'status.sharedStoreCloneCopy',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
if (legacyLocalIndex) {
|
|
console.log(
|
|
t('status.legacyLocalIndex', {
|
|
path: legacyLocalIndex.dir,
|
|
size: formatSlotSize(legacyLocalIndex.bytes),
|
|
}),
|
|
);
|
|
}
|
|
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
|
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
|
|
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
|
|
if (getFtsDisabledReason(activeMeta.capabilities?.fts)) console.log(FTS_DISABLED_MESSAGE);
|
|
// Emit the complete, versioned receipt as JSON so humans can inspect it and
|
|
// automation can compare it without reverse-engineering a display string.
|
|
// `null` is the backward-compatible signal for pre-receipt metadata.
|
|
console.log(
|
|
`${t('status.indexRunnerIdentity')}: ${JSON.stringify(activeMeta.runnerIdentity ?? null)}`,
|
|
);
|
|
if (incompleteReasons.length > 0) {
|
|
console.log(`Index incomplete reasons: ${JSON.stringify(incompleteReasons)}`);
|
|
}
|
|
console.log(`${t('status.currentRunnerIdentity')}: ${JSON.stringify(currentRunnerIdentity)}`);
|
|
if (contentDrift?.kind === 'current') {
|
|
console.log(t('status.indexContentCurrent', { count: contentDrift.coveredFileCount }));
|
|
} else if (contentDrift?.kind === 'drifted') {
|
|
printDriftDetail(contentDrift);
|
|
} else if (contentDrift?.kind === 'unmeasurable') {
|
|
if (contentDrift.reason === 'scan-failed') {
|
|
console.log(t('status.indexContentScanFailed'));
|
|
} else if (!isUpToDate) {
|
|
console.log(t('status.indexContentUnmeasurable', { reason: contentDrift.reason }));
|
|
}
|
|
}
|
|
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
|
|
};
|