mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
06854c405d
50 changed files with 2986 additions and 151 deletions
|
|
@ -73,8 +73,8 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
### Wrong repo in multi-repo setups
|
||||
|
||||
- **Trigger:** Query/impact results belong to another project.
|
||||
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
|
||||
- **Why:** Default target is ambiguous when multiple repos are registered.
|
||||
- **Do:** Confirm an MCP default is configured or the GitNexus process was launched inside the intended registered path without crossing into an unindexed nested Git checkout. Otherwise call `list_repos`, then pass `repo` on subsequent tools; pass it for mutating tools when multiple repos are registered and no MCP default exists.
|
||||
- **Why:** Read-only tools derive their default from MCP configuration or a process cwd that stays within one registered Git boundary. Outside those paths the target remains ambiguous, and mutating tools stay explicit unless configuration supplies the target.
|
||||
|
||||
### LadybugDB lock / "database busy"
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ flowchart TB
|
|||
| `group_list` | List configured repository groups |
|
||||
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
||||
|
||||
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
|
||||
### Resources for instant context
|
||||
|
||||
|
|
@ -612,7 +612,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
|
||||
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
|
||||
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Architecture diagram</strong></summary>
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|||
| `group_list` | List configured repository groups |
|
||||
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
||||
|
||||
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
|
||||
## MCP Resources
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ const PLATFORM_LOGIC = [
|
|||
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
|
||||
// real windows-latest path implementation (#2419/#2420).
|
||||
'test/unit/server-api-repo-resolution.test.ts',
|
||||
// #3073: cwd-based repository selection canonicalizes real paths, compares
|
||||
// platform separators/case, and rejects nested Git-boundary fallthrough.
|
||||
'test/unit/calltool-dispatch.test.ts',
|
||||
// The index write-lock (#2658) selects its backend by process.platform — the
|
||||
// OS socket lock (Windows named pipe / Linux abstract socket) vs the file
|
||||
// fallback — and its socket-backend describe block is gated to linux/win32.
|
||||
|
|
|
|||
|
|
@ -1436,6 +1436,9 @@ const analyzeCommandImpl = async (
|
|||
console.error = origError;
|
||||
bar.stop();
|
||||
console.log(' Already up to date\n');
|
||||
if (runOptions.registryName) {
|
||||
console.log(` Registry name: ${result.repoName}\n`);
|
||||
}
|
||||
if (baseRefRefreshed.length > 0) {
|
||||
console.log(
|
||||
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
|
||||
|
|
|
|||
|
|
@ -219,8 +219,9 @@ export function registerGroupCommands(program: Command): void {
|
|||
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
|
||||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const { syncGroup } = await import('../core/group/sync.js');
|
||||
const { syncGroup, formatGroupSyncAmbiguousError } = await import('../core/group/sync.js');
|
||||
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
|
||||
const { RegistryAmbiguousTargetError } = await import('../storage/repo-manager.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
|
@ -235,6 +236,11 @@ export function registerGroupCommands(program: Command): void {
|
|||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RegistryAmbiguousTargetError) {
|
||||
logger.error(`⚠️ Did not sync group "${name}": ${formatGroupSyncAmbiguousError(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// A sync that could not take the group's lock did NOT run and wrote
|
||||
// nothing (R9 fails closed). That is an operator-actionable outcome, not
|
||||
// a crash, so report it as a failed command rather than letting it
|
||||
|
|
|
|||
|
|
@ -33,6 +33,17 @@ export const en = {
|
|||
'status.workspaceIndexLabel':
|
||||
"Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
|
||||
'status.status': 'Status',
|
||||
'status.indexContentCurrent': 'Index content: matches all {{count}} covered file(s)',
|
||||
'status.indexContentDrifted':
|
||||
'Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted',
|
||||
'status.indexContentMore': ' ...and {{count}} more {{label}}',
|
||||
'status.indexContentUnmeasurable':
|
||||
'Index content: not comparable ({{reason}}); fell back to the working-tree check',
|
||||
'status.indexContentScanFailed':
|
||||
'Index content: coverage scan failed; treating the index as stale',
|
||||
'status.driftChanged': 'changed',
|
||||
'status.driftAdded': 'added',
|
||||
'status.driftDeleted': 'deleted',
|
||||
'status.upToDate': '✅ up-to-date',
|
||||
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
||||
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ export const zhCN = {
|
|||
'status.workspaceIndexLabel':
|
||||
"工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
|
||||
'status.status': '状态',
|
||||
'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致',
|
||||
'status.indexContentDrifted':
|
||||
'索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除',
|
||||
'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}',
|
||||
'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查',
|
||||
'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理',
|
||||
'status.driftChanged': '已修改',
|
||||
'status.driftAdded': '新增',
|
||||
'status.driftDeleted': '已删除',
|
||||
'status.upToDate': '✅ 已是最新',
|
||||
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
||||
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
|
||||
|
|
|
|||
|
|
@ -18,8 +18,69 @@ import {
|
|||
resolveAnalyzerRunnerIdentity,
|
||||
} from '../core/analyzer-identity.js';
|
||||
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
||||
import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js';
|
||||
import { t } from './i18n/index.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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Escape control characters in repo-relative paths before printing. */
|
||||
const formatDriftPath = (rel: string): string =>
|
||||
/[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
export interface StatusOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
|
@ -85,14 +146,36 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
currentRunnerIdentity,
|
||||
);
|
||||
const incompleteReasons = getIndexIncompleteReasons(activeMeta);
|
||||
// A matching HEAD is not enough: `analyze` re-indexes a dirty working tree,
|
||||
// so a repo with uncommitted source changes is stale even at the same commit.
|
||||
// Skip the check for non-git folders (currentCommit === '') to match analyze.
|
||||
const isUpToDate =
|
||||
const metadataIsCurrent =
|
||||
currentCommit === activeMeta.lastCommit &&
|
||||
runnerIdentityIsCurrent &&
|
||||
incompleteReasons.length === 0 &&
|
||||
(currentCommit === '' || !isWorkingTreeDirty(repo.repoPath));
|
||||
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;
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
|
|
@ -111,6 +194,7 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
commit: currentCommit,
|
||||
runnerIdentity: currentRunnerIdentity,
|
||||
},
|
||||
contentDrift: describeContentDrift(contentDrift),
|
||||
status: isUpToDate ? 'up-to-date' : 'stale',
|
||||
}),
|
||||
);
|
||||
|
|
@ -137,5 +221,16 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
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')}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,7 +60,16 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
|
|||
throw new Error('repos is required in group.yaml (must be a mapping)');
|
||||
}
|
||||
|
||||
const repos = raw.repos as Record<string, string>;
|
||||
const reposRaw = raw.repos as Record<string, unknown>;
|
||||
const repos: Record<string, string> = {};
|
||||
for (const [memberPath, registryName] of Object.entries(reposRaw)) {
|
||||
if (typeof registryName !== 'string' || registryName.trim() === '') {
|
||||
throw new Error(
|
||||
`repos["${memberPath}"] must be a non-empty registry name string, not ${typeof registryName}`,
|
||||
);
|
||||
}
|
||||
repos[memberPath] = registryName.trim();
|
||||
}
|
||||
const repoPaths = new Set(Object.keys(repos));
|
||||
|
||||
const rawLinks = (raw.links as unknown[]) || [];
|
||||
|
|
|
|||
|
|
@ -244,6 +244,17 @@ async function resolveGroupRepo(
|
|||
): Promise<GroupRepoHandle | { error: string }> {
|
||||
const registryName = config.repos[repoPath];
|
||||
if (!registryName) {
|
||||
const matchingMemberPaths = Object.entries(config.repos)
|
||||
.filter(([, alias]) => alias.toLowerCase() === repoPath.toLowerCase())
|
||||
.map(([memberPath]) => memberPath);
|
||||
if (matchingMemberPaths.length > 0) {
|
||||
return {
|
||||
error:
|
||||
`Unknown repo path "${repoPath}" in this group. ` +
|
||||
`That value is a registry alias for member path(s): ${matchingMemberPaths.join(', ')}. ` +
|
||||
`Pass the group.yaml key to --repo, not the alias.`,
|
||||
};
|
||||
}
|
||||
return { error: `Unknown repo path "${repoPath}" in this group.` };
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -479,8 +479,9 @@ export class GroupService {
|
|||
// group tools never need it — so deferring it here keeps that closure off
|
||||
// MCP server startup entirely and off every non-sync group call. The CLI
|
||||
// already does exactly this at `cli/group.ts`'s sync command.
|
||||
const { syncGroup } = await import('./sync.js');
|
||||
const { syncGroup, formatGroupSyncAmbiguousError } = await import('./sync.js');
|
||||
const { GroupSyncLockError } = await import('./group-lock.js');
|
||||
const { RegistryAmbiguousTargetError } = await import('../../storage/repo-manager.js');
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
|
|
@ -492,6 +493,9 @@ export class GroupService {
|
|||
// expects. `SyncOptions.verbose` stays for the CLI, which can see them.
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof RegistryAmbiguousTargetError) {
|
||||
return { error: formatGroupSyncAmbiguousError(err) };
|
||||
}
|
||||
// Fails closed (R9): this sync could not be protected against a concurrent
|
||||
// one, so it did not run and wrote nothing. Return it through the same
|
||||
// error channel a missing group uses — NEVER as a success payload of zeroes,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ import {
|
|||
getMaxResidentRepos,
|
||||
} from '../lbug/pool-adapter.js';
|
||||
import {
|
||||
findRegistryEntryByName,
|
||||
canonicalizePath,
|
||||
registryPathEquals,
|
||||
readRegistry,
|
||||
readRegistryStrict,
|
||||
RegistryAmbiguousTargetError,
|
||||
type RegistryEntry,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import type {
|
||||
|
|
@ -128,9 +132,19 @@ export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry
|
|||
return base;
|
||||
}
|
||||
|
||||
/** Operator copy for group sync — unique `--name`, not a path in yaml. */
|
||||
export function formatGroupSyncAmbiguousError(err: RegistryAmbiguousTargetError): string {
|
||||
const listing = err.matches.map((m) => ` - ${m.path}`).join('\n');
|
||||
return (
|
||||
`Multiple registered repos are named "${err.target}":\n${listing}\n` +
|
||||
`Give each clone a unique registry name with \`gitnexus analyze --name\`, then re-sync. ` +
|
||||
`Do not put a filesystem path in group.yaml.`
|
||||
);
|
||||
}
|
||||
|
||||
function defaultResolveHandle(allEntries: RegistryEntry[]) {
|
||||
return async (registryName: string, groupPath: string): Promise<RepoHandle | null> => {
|
||||
const e = allEntries.find((en) => en.name === registryName);
|
||||
const e = findRegistryEntryByName(allEntries, registryName);
|
||||
if (!e) return null;
|
||||
const poolId = stableRepoPoolId(e, allEntries);
|
||||
return {
|
||||
|
|
@ -277,7 +291,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
// Group-path → pool identity for repos that successfully initialized. Drives
|
||||
// windowed manifest resolution below (re-init + lease per window). Keyed by
|
||||
// group path because manifest links reference repos by group path.
|
||||
const repoHandles = new Map<string, { poolId: string; lbugPath: string }>();
|
||||
const repoHandles = new Map<string, { poolId: string; lbugPath: string; repoPath: string }>();
|
||||
// Keep resolved disk paths even when extraction fails and removes the
|
||||
// corresponding handle; workspace discovery does not need a readable index.
|
||||
const resolvedRepoPaths = new Map<string, string>();
|
||||
// Every eviction lease this sync holds. Window loops release their own leases
|
||||
// (bounding residency); this set is the defensive outer-finally sweep —
|
||||
// release disposers are idempotent, so double-release is a safe no-op.
|
||||
|
|
@ -295,6 +312,11 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
registryEntries = await readRegistryStrict();
|
||||
const entries = registryEntries;
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
if (!opts?.resolveRepoHandle) {
|
||||
for (const regName of Object.values(config.repos)) {
|
||||
findRegistryEntryByName(entries, regName);
|
||||
}
|
||||
}
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const graphqlEx = new GraphqlExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
|
|
@ -308,6 +330,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
missingRepos.push(groupPath);
|
||||
continue;
|
||||
}
|
||||
resolvedRepoPaths.set(groupPath, handle.repoPath);
|
||||
|
||||
const poolId = handle.id;
|
||||
const lbugPath = path.join(handle.storagePath, 'lbug');
|
||||
|
|
@ -327,7 +350,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
// resolution no longer reuses these executors — it re-inits + leases
|
||||
// each repo per window (see windowed resolution below, issue #2189).
|
||||
// Record the pool identity so windowed resolution can re-init.
|
||||
repoHandles.set(groupPath, { poolId, lbugPath });
|
||||
repoHandles.set(groupPath, { poolId, lbugPath, repoPath: handle.repoPath });
|
||||
|
||||
const executor: CypherExecutor = (query, params) =>
|
||||
executeParameterized(poolId, query, params ?? {});
|
||||
|
|
@ -409,7 +432,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
lastCommit: m.lastCommit || '',
|
||||
};
|
||||
} catch {
|
||||
const e = entries.find((en) => en.name === regName);
|
||||
const resolvedHandlePath = canonicalizePath(handle.repoPath);
|
||||
const e = entries.find((en) =>
|
||||
registryPathEquals(canonicalizePath(en.path), resolvedHandlePath),
|
||||
);
|
||||
repoSnapshots[groupPath] = {
|
||||
indexedAt: e?.indexedAt || '',
|
||||
lastCommit: e?.lastCommit || '',
|
||||
|
|
@ -431,6 +457,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
// read. The loop bounds the append by memory instead.
|
||||
for (const contract of repoContracts) autoContracts.push(contract);
|
||||
} catch (err) {
|
||||
if (err instanceof RegistryAmbiguousTargetError) throw err;
|
||||
// This spans initLbug plus all contract extraction for the repo. The
|
||||
// error used to be discarded entirely, so the only trace of (say) a
|
||||
// storage-version mismatch was an empty contracts.json and a later
|
||||
|
|
@ -465,7 +492,13 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
const repoPaths = new Map<string, string>();
|
||||
if (!registryEntries) registryEntries = await readRegistry();
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const e = registryEntries.find((en) => en.name === regName);
|
||||
const resolvedPath = resolvedRepoPaths.get(groupPath);
|
||||
if (resolvedPath) {
|
||||
repoPaths.set(groupPath, resolvedPath);
|
||||
continue;
|
||||
}
|
||||
if (opts?.resolveRepoHandle) continue;
|
||||
const e = findRegistryEntryByName(registryEntries, regName);
|
||||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
|
|
|
|||
170
gitnexus/src/core/index-content-drift.ts
Normal file
170
gitnexus/src/core/index-content-drift.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Does the index still reflect the files it actually covers?
|
||||
*
|
||||
* `status` used to answer this with a repo-wide `git status --porcelain`
|
||||
* boolean, which says something different: whether the working tree differs
|
||||
* from HEAD. Those two questions diverge in both directions. A scratch file,
|
||||
* a build artifact, or a tracked file under a tool directory the indexer
|
||||
* never reads makes the tree dirty while every indexed file is byte-current —
|
||||
* and because `analyze` cannot commit or delete that file, the resulting
|
||||
* "stale (re-run gitnexus analyze)" verdict was unclearable (#3077). It also
|
||||
* misses the reverse case: reverting a file that was indexed while dirty
|
||||
* leaves a clean tree over an index holding the pre-revert content.
|
||||
*
|
||||
* `meta.fileHashes` already records the exact set of files the last run
|
||||
* covered, so the question can be answered directly. This module recomputes
|
||||
* the coverage set with the same `walkRepositoryPaths` scan (ignore rules and
|
||||
* dotfile handling stay shared) and the large-file cap recorded in
|
||||
* `meta.indexCoverage`, hashes only the paths that can actually have changed
|
||||
* since that run, and diffs against what was recorded.
|
||||
*/
|
||||
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { walkRepositoryPaths } from './ingestion/filesystem-walker.js';
|
||||
import { computeFileHashesDetailed } from '../storage/file-hash.js';
|
||||
import { listWorkingTreeDirtyPaths } from '../storage/git.js';
|
||||
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
|
||||
import { chunk } from '../lib/utils.js';
|
||||
import { logger } from './logger.js';
|
||||
import type { RepoMeta } from '../storage/repo-meta.js';
|
||||
|
||||
/** Why the recorded coverage set could not be compared against disk at all. */
|
||||
export type IndexContentUnmeasurableReason =
|
||||
/** Metadata predates per-file hashes, or the run recorded none (non-git). */
|
||||
| 'no-file-hashes'
|
||||
/** The repository scan or hashing pass threw. */
|
||||
| 'scan-failed';
|
||||
|
||||
/**
|
||||
* A three-way verdict. `'unmeasurable'` is kept apart from `'current'` on
|
||||
* purpose: it means the comparison never ran, which is not evidence the index
|
||||
* is fresh. Legacy metadata without hashes still falls back to the working-tree
|
||||
* check; a failed scan must not.
|
||||
*/
|
||||
export type IndexContentDrift =
|
||||
| { kind: 'current'; coveredFileCount: number }
|
||||
| { kind: 'drifted'; changed: string[]; added: string[]; deleted: string[] }
|
||||
| { kind: 'unmeasurable'; reason: IndexContentUnmeasurableReason };
|
||||
|
||||
export type IndexCoveragePolicy = NonNullable<RepoMeta['indexCoverage']>;
|
||||
|
||||
const HASH_BATCH = 100;
|
||||
|
||||
const collectUnreadablePaths = async (
|
||||
repoPath: string,
|
||||
relPaths: readonly string[],
|
||||
): Promise<string[]> => {
|
||||
const unreadable: string[] = [];
|
||||
for (const batch of chunk(relPaths, HASH_BATCH)) {
|
||||
await Promise.all(
|
||||
batch.map(async (rel) => {
|
||||
try {
|
||||
await access(path.join(repoPath, rel), fsConstants.R_OK);
|
||||
} catch {
|
||||
unreadable.push(rel);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
unreadable.sort();
|
||||
return unreadable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare the files recorded in `fileHashes` against the current working tree.
|
||||
*
|
||||
* `added` covers files the index would pick up but has never seen, so a new
|
||||
* source file still reports stale — the index is genuinely incomplete then,
|
||||
* and comparing only the recorded entries would wave that through.
|
||||
*/
|
||||
export const detectIndexContentDrift = async (
|
||||
repoPath: string,
|
||||
fileHashes: Readonly<Record<string, string>> | undefined,
|
||||
coverage?: IndexCoveragePolicy,
|
||||
): Promise<IndexContentDrift> => {
|
||||
if (!fileHashes || Object.keys(fileHashes).length === 0) {
|
||||
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
|
||||
}
|
||||
|
||||
// Excluded from BOTH sides, or GitNexus's own output guarantees a mismatch:
|
||||
// analyze rewrites AGENTS.md/CLAUDE.md after recording hashes, so they read
|
||||
// as `added` on a first run and `changed` on every run after that — a fresh
|
||||
// index would report itself stale forever.
|
||||
const recorded = Object.fromEntries(
|
||||
Object.entries(fileHashes).filter(([rel]) => !isGitNexusManagedPath(rel)),
|
||||
);
|
||||
if (Object.keys(recorded).length === 0) {
|
||||
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
|
||||
}
|
||||
|
||||
try {
|
||||
const scanned = await walkRepositoryPaths(repoPath, undefined, {
|
||||
quiet: true,
|
||||
maxFileSizeBytes: coverage?.maxFileSizeBytes,
|
||||
});
|
||||
const scannedPaths = scanned.map((file) => file.path).filter((p) => !isGitNexusManagedPath(p));
|
||||
const scannedSet = new Set(scannedPaths);
|
||||
const recordedSet = new Set(Object.keys(recorded));
|
||||
|
||||
// Legacy indexes have `fileHashes` but no `indexCoverage`. A later default
|
||||
// cap would omit a still-present hashed file and call it deleted. Recorded
|
||||
// paths that still exist stay in the coverage set even if this walk skipped
|
||||
// them for size.
|
||||
const recovered = new Set<string>();
|
||||
for (const rel of recordedSet) {
|
||||
if (scannedSet.has(rel)) continue;
|
||||
try {
|
||||
await access(path.join(repoPath, rel), fsConstants.R_OK);
|
||||
recovered.add(rel);
|
||||
scannedSet.add(rel);
|
||||
} catch {
|
||||
// Missing or unreadable: stays deleted / changed below.
|
||||
}
|
||||
}
|
||||
|
||||
const added = scannedPaths.filter((p) => !recordedSet.has(p)).sort();
|
||||
const deleted = [...recordedSet].filter((p) => !scannedSet.has(p)).sort();
|
||||
const intersection = [...recordedSet].filter((p) => scannedSet.has(p));
|
||||
|
||||
const dirtyNow = listWorkingTreeDirtyPaths(repoPath);
|
||||
const dirtyAtIndex = coverage?.dirtyPaths;
|
||||
const dirtyNowSet = dirtyNow === null ? null : new Set(dirtyNow);
|
||||
const dirtyAtIndexSet = dirtyAtIndex === undefined ? undefined : new Set(dirtyAtIndex);
|
||||
const hashCandidates =
|
||||
dirtyNowSet === null || dirtyAtIndexSet === undefined
|
||||
? intersection
|
||||
: intersection.filter(
|
||||
(p) => dirtyAtIndexSet.has(p) || dirtyNowSet.has(p) || recovered.has(p),
|
||||
);
|
||||
|
||||
const hashCandidateSet = new Set(hashCandidates);
|
||||
const skipHash = intersection.filter((p) => !hashCandidateSet.has(p));
|
||||
const unreadableFromAccess = await collectUnreadablePaths(repoPath, skipHash);
|
||||
const unreadableSet = new Set(unreadableFromAccess);
|
||||
const { hashes: hashed, unreadable: unreadableFromHash } = await computeFileHashesDetailed(
|
||||
repoPath,
|
||||
hashCandidates,
|
||||
);
|
||||
for (const p of unreadableFromHash) unreadableSet.add(p);
|
||||
const changed: string[] = [];
|
||||
for (const p of intersection) {
|
||||
if (unreadableSet.has(p)) {
|
||||
changed.push(p);
|
||||
continue;
|
||||
}
|
||||
const currentHash = hashed.get(p) ?? recorded[p];
|
||||
if (currentHash !== recorded[p]) changed.push(p);
|
||||
}
|
||||
changed.sort();
|
||||
|
||||
if (changed.length === 0 && added.length === 0 && deleted.length === 0) {
|
||||
return { kind: 'current', coveredFileCount: scannedSet.size };
|
||||
}
|
||||
return { kind: 'drifted', changed, added, deleted };
|
||||
} catch (err) {
|
||||
logger.warn({ err, repoPath }, 'index content drift scan failed');
|
||||
return { kind: 'unmeasurable', reason: 'scan-failed' };
|
||||
}
|
||||
};
|
||||
|
|
@ -57,16 +57,49 @@ const warnLargeFileSkip = (message: string): void => {
|
|||
logger.warn(message);
|
||||
};
|
||||
|
||||
export interface WalkRepositoryOptions {
|
||||
/**
|
||||
* Suppress the operator-facing large-file notice. Set by read-only callers
|
||||
* such as `status`, which reuse this scan purely to learn which files the
|
||||
* index covers and must not emit analyze's progress commentary.
|
||||
*/
|
||||
quiet?: boolean;
|
||||
/**
|
||||
* Override the large-file cap. `status` replays the bytes recorded at
|
||||
* analyze time so `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot
|
||||
* silently drop a file that the index actually covers.
|
||||
*/
|
||||
maxFileSizeBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
|
||||
* Memory: ~10MB for 100K files vs ~1GB+ with content.
|
||||
*/
|
||||
const assertWalkRootIsDirectory = async (repoPath: string): Promise<void> => {
|
||||
let st;
|
||||
try {
|
||||
st = await fs.stat(repoPath);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`walkRepositoryPaths: path does not exist: ${repoPath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
throw new Error(`walkRepositoryPaths: not a directory: ${repoPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const walkRepositoryPaths = async (
|
||||
repoPath: string,
|
||||
onProgress?: (current: number, total: number, filePath: string) => void,
|
||||
options: WalkRepositoryOptions = {},
|
||||
): Promise<ScannedFile[]> => {
|
||||
await assertWalkRootIsDirectory(repoPath);
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
const maxFileSizeBytes = getMaxFileSizeBytes();
|
||||
const maxFileSizeBytes = options.maxFileSizeBytes ?? getMaxFileSizeBytes();
|
||||
|
||||
const filtered = await glob('**/*', {
|
||||
cwd: repoPath,
|
||||
|
|
@ -117,7 +150,7 @@ export const walkRepositoryPaths = async (
|
|||
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
||||
);
|
||||
|
||||
if (skippedLarge > 0) {
|
||||
if (skippedLarge > 0 && !options.quiet) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const suffix = isDefault ? ', likely generated/vendored' : '';
|
||||
|
|
|
|||
|
|
@ -30,20 +30,31 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
|
|||
message: 'Scanning repository...',
|
||||
});
|
||||
|
||||
const scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
|
||||
const scanProgress = Math.round((current / total) * 15);
|
||||
ctx.onProgress({
|
||||
phase: 'extracting',
|
||||
percent: scanProgress,
|
||||
message: 'Scanning repository...',
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: current,
|
||||
totalFiles: total,
|
||||
nodesCreated: ctx.graph.nodeCount,
|
||||
},
|
||||
let scannedFiles;
|
||||
try {
|
||||
scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
|
||||
const scanProgress = Math.round((current / total) * 15);
|
||||
ctx.onProgress({
|
||||
phase: 'extracting',
|
||||
percent: scanProgress,
|
||||
message: 'Scanning repository...',
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: current,
|
||||
totalFiles: total,
|
||||
nodesCreated: ctx.graph.nodeCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Missing roots throw so status cannot treat an empty glob as "every
|
||||
// covered file was deleted". The pipeline still reports an empty scan
|
||||
// for a path that is not a directory, matching analyze of a bad cwd.
|
||||
if (err instanceof Error && err.message.startsWith('walkRepositoryPaths:')) {
|
||||
return { scannedFiles: [], allPaths: [], totalFiles: 0 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const totalFiles = scannedFiles.length;
|
||||
const allPaths = scannedFiles.map((f) => f.path);
|
||||
|
|
|
|||
|
|
@ -153,8 +153,11 @@ import {
|
|||
hasGitDir,
|
||||
getInferredRepoName,
|
||||
isWorkingTreeDirty,
|
||||
listWorkingTreeDirtyPaths,
|
||||
resolveRepoIdentityRoot,
|
||||
} from '../storage/git.js';
|
||||
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
|
||||
import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js';
|
||||
import type { CachedEmbedding } from './embeddings/types.js';
|
||||
import { generateAIContextFiles } from '../cli/ai-context.js';
|
||||
import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
||||
|
|
@ -1305,6 +1308,13 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
progress('done', 100, 'Done');
|
||||
if (options.registryName) {
|
||||
await registerRepo(repoPath, existingMeta, {
|
||||
name: options.registryName,
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
branch: placement.branch,
|
||||
});
|
||||
}
|
||||
return {
|
||||
repoName:
|
||||
options.registryName ??
|
||||
|
|
@ -1684,6 +1694,35 @@ async function runFullAnalysisInner(
|
|||
// later read on a host where it loads — which is a legitimate, common
|
||||
// state, and the invariant `analyzer-identity-cli.test.ts` pins.
|
||||
if (!dirty && !healUnregistered) {
|
||||
if (options.registryName) {
|
||||
await registerRepo(repoPath, existingMeta, {
|
||||
name: options.registryName,
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
branch: placement.branch,
|
||||
});
|
||||
if (!placement.branch) {
|
||||
try {
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
storagePath,
|
||||
options.registryName,
|
||||
existingMeta.stats ?? {},
|
||||
undefined,
|
||||
{
|
||||
skipAgentsMd: options.skipAgentsMd,
|
||||
skipSkills: options.skipSkills,
|
||||
noStats: options.noStats,
|
||||
defaultBranch: options.defaultBranch,
|
||||
// Fast path does not re-run PDG. Using `options.pdg` would
|
||||
// strip PDG bullets from AGENTS.md on a rename-only analyze.
|
||||
hasPdg: existingMeta.pdg != null,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
/* best-effort — never fail the fast path over a context refresh */
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── #2354: restamp the workspace label on a same-commit branch flip ──
|
||||
// The flat slot follows the checked-out working tree; a branch switch
|
||||
// at the SAME commit with a clean tree changes nothing the pipeline
|
||||
|
|
@ -3642,6 +3681,16 @@ async function runFullAnalysisInner(
|
|||
// absence has exactly one meaning — an index older than the field.
|
||||
embeddingDims: EMBEDDING_DIMS,
|
||||
fileHashes: hasGitDir(repoPath) ? newFileHashesRecord : undefined,
|
||||
indexCoverage: hasGitDir(repoPath)
|
||||
? {
|
||||
maxFileSizeBytes: getMaxFileSizeBytes(),
|
||||
dirtyPaths: (
|
||||
listWorkingTreeDirtyPaths(repoPath) ?? Object.keys(newFileHashesRecord)
|
||||
).filter(
|
||||
(rel) => newFileHashesRecord[rel] !== undefined && !isGitNexusManagedPath(rel),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
// This branch's full live chunk-key set (#2106 R6). `usedKeys` is every
|
||||
// chunk hash touched in this scan — cache HITS included (see parse-impl
|
||||
// usedKeys.add) — so it's complete even on an incremental run. Persisted
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
parseDiffHunks,
|
||||
coalesceHunksByPath,
|
||||
hunksOverlapRange,
|
||||
findGitRootByDotGit,
|
||||
getCanonicalRepoRoot,
|
||||
getGitRoot,
|
||||
type FileDiff,
|
||||
|
|
@ -1716,21 +1717,61 @@ export class LocalBackend {
|
|||
* - If only 1 repo, use it
|
||||
* - If 0 or multiple without param, throw with helpful message
|
||||
*
|
||||
* On a miss, re-reads the registry once in case a new repo was indexed
|
||||
* while the MCP server was running.
|
||||
* Re-reads the registry before an omitted implicit target or after an
|
||||
* explicit miss, so long-running servers see newly indexed repositories.
|
||||
*/
|
||||
async resolveRepo(repoParam?: string, branch?: string): Promise<RepoHandle> {
|
||||
let refreshedAfterAmbiguity = false;
|
||||
return this.selectToolRepository(repoParam, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal resolver variant for CLI/MCP tool routing and discovery.
|
||||
* - If repoParam is given, match by name or path
|
||||
* - If only 1 repo, use it
|
||||
* - If multiple repos exist and repoParam is omitted, callers may opt in to
|
||||
* the registered repo containing process.cwd()
|
||||
* - If 0 repos exist, or cwd cannot disambiguate multiple repos, throw
|
||||
*
|
||||
* Omitted-repo resolution re-reads the registry before accepting any
|
||||
* implicit target, including a cached singleton. A caller that just obtained
|
||||
* a fresh registry snapshot may disable that refresh explicitly.
|
||||
*/
|
||||
async selectToolRepository(
|
||||
repoParam?: string,
|
||||
branch?: string,
|
||||
options: { allowCwdDefault?: boolean; refreshRegistry?: boolean } = {},
|
||||
): Promise<RepoHandle> {
|
||||
const allowCwdDefault = options.allowCwdDefault === true;
|
||||
const mayRefresh = options.refreshRegistry !== false;
|
||||
let refreshed = false;
|
||||
|
||||
// A cached singleton is also an implicit choice: another process may have
|
||||
// registered a second repo since init, which must not let a repo-less
|
||||
// mutating call bypass the multi-repo ambiguity guard.
|
||||
if (!repoParam && mayRefresh) {
|
||||
await this.refreshRepos();
|
||||
refreshed = true;
|
||||
}
|
||||
|
||||
let result: RepoHandle | null;
|
||||
try {
|
||||
result = this.resolveRepoFromCache(repoParam);
|
||||
result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
|
||||
} catch (err) {
|
||||
if (!(err instanceof RegistryAmbiguousTargetError)) throw err;
|
||||
if (!mayRefresh || refreshed) throw err;
|
||||
// Stale in-memory duplicate siblings can linger after unregister; refresh
|
||||
// once before re-throwing so a resolved registry can disambiguate (#1658).
|
||||
await this.refreshRepos();
|
||||
refreshedAfterAmbiguity = true;
|
||||
result = this.resolveRepoFromCache(repoParam);
|
||||
refreshed = true;
|
||||
result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
|
||||
}
|
||||
|
||||
// Explicit misses retain the existing one-refresh retry. Omitted targets
|
||||
// already refreshed above unless a same-snapshot caller opted out.
|
||||
if (!result && mayRefresh && !refreshed) {
|
||||
await this.refreshRepos();
|
||||
refreshed = true;
|
||||
result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
|
|
@ -1746,16 +1787,6 @@ export class LocalBackend {
|
|||
return this.applyBranchScope(result, branch);
|
||||
}
|
||||
|
||||
// Miss — refresh registry and try once more (skip if already refreshed above)
|
||||
if (!refreshedAfterAmbiguity) {
|
||||
await this.refreshRepos();
|
||||
}
|
||||
const retried = this.resolveRepoFromCache(repoParam);
|
||||
if (retried) {
|
||||
this.maybeWarnSiblingDrift(retried).catch(() => {});
|
||||
return this.applyBranchScope(retried, branch);
|
||||
}
|
||||
|
||||
// Still no match — throw with helpful message
|
||||
if (this.repos.size === 0) {
|
||||
throw new Error('No indexed repositories. Run: gitnexus analyze');
|
||||
|
|
@ -1905,7 +1936,7 @@ export class LocalBackend {
|
|||
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
|
||||
* multiple handles by name and cwd cannot disambiguate (#1658).
|
||||
*/
|
||||
private resolveRepoFromCache(repoParam?: string): RepoHandle | null {
|
||||
private resolveRepoFromCache(repoParam?: string, allowCwdDefault = false): RepoHandle | null {
|
||||
if (this.repos.size === 0) return null;
|
||||
|
||||
if (repoParam) {
|
||||
|
|
@ -1938,6 +1969,9 @@ export class LocalBackend {
|
|||
);
|
||||
if (nameMatches.length === 1) return nameMatches[0];
|
||||
if (nameMatches.length > 1) {
|
||||
// Explicit duplicate aliases retain the legacy fail-closed contract:
|
||||
// only an exact cwd Git-root match may disambiguate them. Deepest path
|
||||
// containment is reserved for an omitted read-only repo (#3073).
|
||||
const cwdPick = this.pickRepoHandleForCwd(nameMatches);
|
||||
if (cwdPick) return cwdPick;
|
||||
throw new RegistryAmbiguousTargetError(
|
||||
|
|
@ -1969,26 +2003,50 @@ export class LocalBackend {
|
|||
return this.repos.values().next().value!;
|
||||
}
|
||||
|
||||
if (allowCwdDefault) {
|
||||
const cwdPick = this.pickRepoHandleForCwd([...this.repos.values()], true);
|
||||
if (cwdPick) return cwdPick;
|
||||
}
|
||||
|
||||
return null; // Multiple repos, no param — ambiguous
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the indexed repo whose path matches the git root of process.cwd().
|
||||
* Match process.cwd() against indexed repositories.
|
||||
*
|
||||
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
|
||||
* not the agent client's cwd. If the server was started from an unrelated
|
||||
* directory, `getGitRoot` returns null and duplicate-name resolution throws
|
||||
* {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
|
||||
* Explicit duplicate aliases use exact Git-root matching only. Omitted
|
||||
* read-only calls opt into deepest containing-path selection. In that mode a
|
||||
* candidate must not sit above cwd's Git root, so an unindexed nested checkout
|
||||
* cannot fall through to an indexed ancestor. The `.git` ancestor fallback
|
||||
* preserves that boundary when the git executable is unavailable.
|
||||
*/
|
||||
private pickRepoHandleForCwd(candidates: RepoHandle[]): RepoHandle | null {
|
||||
const cwdRoot = getGitRoot(process.cwd());
|
||||
if (!cwdRoot) return null;
|
||||
const canonicalCwd = canonicalizePath(cwdRoot);
|
||||
private pickRepoHandleForCwd(
|
||||
candidates: RepoHandle[],
|
||||
allowContaining = false,
|
||||
): RepoHandle | null {
|
||||
const cwd = process.cwd();
|
||||
const normalize = (value: string): string => {
|
||||
const canonical = canonicalizePath(value);
|
||||
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
};
|
||||
const isSameOrDescendant = (parent: string, child: string): boolean =>
|
||||
child === parent ||
|
||||
child.startsWith(parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`);
|
||||
const canonicalCwd = normalize(cwd);
|
||||
const cwdRoot = getGitRoot(cwd) ?? findGitRootByDotGit(cwd);
|
||||
const canonicalRoot = cwdRoot ? normalize(cwdRoot) : null;
|
||||
if (allowContaining) {
|
||||
const containing = candidates
|
||||
.map((handle) => ({ handle, repoPath: normalize(handle.repoPath) }))
|
||||
.filter(({ repoPath }) => isSameOrDescendant(repoPath, canonicalCwd))
|
||||
.filter(({ repoPath }) => !canonicalRoot || isSameOrDescendant(canonicalRoot, repoPath))
|
||||
.sort((a, b) => b.repoPath.length - a.repoPath.length);
|
||||
if (containing.length > 0) return containing[0].handle;
|
||||
}
|
||||
|
||||
if (!canonicalRoot) return null;
|
||||
const cwdMatches = candidates.filter((handle) => {
|
||||
const stored = canonicalizePath(handle.repoPath);
|
||||
return process.platform === 'win32'
|
||||
? stored.toLowerCase() === canonicalCwd.toLowerCase()
|
||||
: stored === canonicalCwd;
|
||||
return normalize(handle.repoPath) === canonicalRoot;
|
||||
});
|
||||
return cwdMatches.length === 1 ? cwdMatches[0] : null;
|
||||
}
|
||||
|
|
@ -2415,9 +2473,10 @@ export class LocalBackend {
|
|||
|
||||
// Resolve repo from optional param (re-reads registry on miss). An optional
|
||||
// `branch` param scopes the resolved handle to that branch's index (#2106).
|
||||
const repo = await this.resolveRepo(
|
||||
const repo = await this.selectToolRepository(
|
||||
p.repo as string | undefined,
|
||||
p.branch as string | undefined,
|
||||
{ allowCwdDefault: method !== 'rename' },
|
||||
);
|
||||
|
||||
switch (method) {
|
||||
|
|
|
|||
|
|
@ -179,9 +179,44 @@ export class McpRepositoryPolicy {
|
|||
});
|
||||
}
|
||||
|
||||
async requiresExplicitRepo(backend: LocalBackend): Promise<boolean> {
|
||||
if (this.defaultRepo) return false;
|
||||
return (await this.listAllowedRepos(backend)).length > 1;
|
||||
async toolSchemaRepoRequirements(backend: LocalBackend): Promise<{
|
||||
readOnlyRequiresRepo: boolean;
|
||||
mutatingRequiresRepo: boolean;
|
||||
}> {
|
||||
if (this.defaultRepo) {
|
||||
return { readOnlyRequiresRepo: false, mutatingRequiresRepo: false };
|
||||
}
|
||||
|
||||
// Runtime selection is based on the configured allowlist, not on which
|
||||
// entries happen to remain visible in a later registry refresh. Keep the
|
||||
// advertised schema aligned with repoForArgs() when that listing shrinks.
|
||||
if (this.restricted) {
|
||||
const requiresRepo = this.allowed.length > 1;
|
||||
return {
|
||||
readOnlyRequiresRepo: requiresRepo,
|
||||
mutatingRequiresRepo: requiresRepo,
|
||||
};
|
||||
}
|
||||
|
||||
// One fresh listing supplies both schema decisions. Besides keeping the
|
||||
// advertised contract internally consistent, this avoids doing two full
|
||||
// per-repo staleness fan-outs for every tools/list request.
|
||||
const visibleRepos = await this.listAllowedRepos(backend);
|
||||
if (visibleRepos.length <= 1) {
|
||||
return { readOnlyRequiresRepo: false, mutatingRequiresRepo: false };
|
||||
}
|
||||
try {
|
||||
// listAllowedRepos() refreshed this backend immediately above. Resolve
|
||||
// against that exact cache snapshot instead of racing another registry
|
||||
// read; only read-only schemas may advertise the cwd-derived default.
|
||||
await backend.selectToolRepository(undefined, undefined, {
|
||||
allowCwdDefault: true,
|
||||
refreshRegistry: false,
|
||||
});
|
||||
return { readOnlyRequiresRepo: false, mutatingRequiresRepo: true };
|
||||
} catch {
|
||||
return { readOnlyRequiresRepo: true, mutatingRequiresRepo: true };
|
||||
}
|
||||
}
|
||||
|
||||
private async listReposPage(
|
||||
|
|
@ -241,6 +276,22 @@ export class McpRepositoryPolicy {
|
|||
return backend.resolveRepo(selected?.path, branch);
|
||||
}
|
||||
|
||||
private async selectToolRepository(
|
||||
backend: LocalBackend,
|
||||
repo?: string,
|
||||
branch?: string,
|
||||
options?: Parameters<LocalBackend['selectToolRepository']>[2],
|
||||
): Promise<Awaited<ReturnType<LocalBackend['selectToolRepository']>>> {
|
||||
if (!this.configured) return backend.selectToolRepository(repo, branch, options);
|
||||
if (!this.restricted) {
|
||||
return backend.selectToolRepository(repo ?? this.defaultRepo?.path, branch, options);
|
||||
}
|
||||
const selected = this.repoForArgs(repo === undefined ? undefined : { repo });
|
||||
// Restricted policies never allow cwd to select outside the configured
|
||||
// set; once policy supplies an explicit path, the public resolver is enough.
|
||||
return backend.resolveRepo(selected?.path, branch);
|
||||
}
|
||||
|
||||
assertResourceUri(uri: string): void {
|
||||
if (!this.restricted) return;
|
||||
let parsed: URL;
|
||||
|
|
@ -307,6 +358,13 @@ export class McpRepositoryPolicy {
|
|||
if (property === 'resolveRepo') {
|
||||
return (repo?: string, branch?: string) => policy.resolveRepo(target, repo, branch);
|
||||
}
|
||||
if (property === 'selectToolRepository') {
|
||||
return (
|
||||
repo?: string,
|
||||
branch?: string,
|
||||
options?: Parameters<LocalBackend['selectToolRepository']>[2],
|
||||
) => policy.selectToolRepository(target, repo, branch, options);
|
||||
}
|
||||
if (property === 'getContext' && policy.restricted) {
|
||||
return (repoId?: string) => {
|
||||
if (!repoId || !policy.uniqueAllowedContextNames.has(repoId.toLowerCase())) return null;
|
||||
|
|
|
|||
|
|
@ -313,7 +313,10 @@ async function getReposResource(backend: LocalBackend): Promise<string> {
|
|||
|
||||
if (repos.length > 1) {
|
||||
lines.push('');
|
||||
lines.push('# Multiple repos indexed. Use repo parameter in tool calls:');
|
||||
lines.push(
|
||||
'# Multiple repos indexed. Read-only tools may omit repo when an MCP default is configured or GitNexus process.cwd() is inside one listed path without crossing an unindexed nested Git checkout.',
|
||||
);
|
||||
lines.push('# Otherwise—and for mutating tools without an MCP default—pass repo explicitly:');
|
||||
lines.push(`# query({search_query: "auth", repo: "${repos[0].name}"})`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,11 +185,12 @@ export function createMCPServer(
|
|||
}
|
||||
});
|
||||
|
||||
// With multiple visible repositories and no process-wide default, make the
|
||||
// routing requirement machine-readable. Agents then supply `repo` before the
|
||||
// call instead of discovering the ambiguity through a failed tool response.
|
||||
// Make the effective routing contract machine-readable. Read-only tools may
|
||||
// use a cwd-derived default; mutating rename remains explicit unless policy
|
||||
// supplies a single/default repository.
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
const requireRepo = await repositoryPolicy.requiresExplicitRepo(backend);
|
||||
const { readOnlyRequiresRepo, mutatingRequiresRepo } =
|
||||
await repositoryPolicy.toolSchemaRepoRequirements(backend);
|
||||
return {
|
||||
tools: GITNEXUS_TOOLS.filter(
|
||||
(tool) =>
|
||||
|
|
@ -201,7 +202,8 @@ export function createMCPServer(
|
|||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema:
|
||||
requireRepo && REPO_SCOPED_TOOLS.has(tool.name)
|
||||
(tool.name === 'rename' ? mutatingRequiresRepo : readOnlyRequiresRepo) &&
|
||||
REPO_SCOPED_TOOLS.has(tool.name)
|
||||
? {
|
||||
...tool.inputSchema,
|
||||
required: [...new Set([...tool.inputSchema.required, 'repo'])],
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ export const PDG_QUERY_MAX_LIMIT = 200;
|
|||
// PDG direct backend callers also enforce it before running traversal.
|
||||
export const IMPACT_MAX_DEPTH = 32;
|
||||
|
||||
const CWD_AWARE_REPO_OMISSION =
|
||||
'Omit when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing an unindexed nested Git checkout; otherwise specify it explicitly.';
|
||||
const MUTATING_REPO_OMISSION =
|
||||
'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.';
|
||||
|
||||
export const GITNEXUS_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: 'list_repos',
|
||||
|
|
@ -94,8 +99,10 @@ PAGINATION: Results are paginated so a large registry is not truncated by MCP/LL
|
|||
WHEN TO USE: First step when multiple repos are indexed, or to discover available repos.
|
||||
AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work with.
|
||||
|
||||
When multiple repos are indexed, you MUST specify the "repo" parameter
|
||||
on other tools (query, context, impact, etc.) to target the correct one.`,
|
||||
When multiple repos are indexed, repo-scoped read-only tools use the configured
|
||||
MCP default or the registered path containing the GitNexus process cwd, unless
|
||||
cwd has crossed into an unindexed nested Git checkout. If neither applies,
|
||||
specify the "repo" parameter explicitly.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
|
|
@ -184,8 +191,7 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>" (member path keys from group.yaml). Omit when only one indexed repo exists.',
|
||||
description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>" (member path keys from group.yaml). ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
service: {
|
||||
type: 'string',
|
||||
|
|
@ -266,7 +272,7 @@ TIPS:
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: ['statement'],
|
||||
|
|
@ -331,8 +337,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". Omit if only one repo is indexed.',
|
||||
description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
service: {
|
||||
type: 'string',
|
||||
|
|
@ -378,7 +383,7 @@ Returns: changed symbols, affected processes, and a risk summary.
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
@ -417,7 +422,7 @@ A graph too large to analyze at all returns \`{ error, truncated: true }\` with
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
@ -454,7 +459,7 @@ Handles disambiguation via context()'s payload verbatim: an ambiguous symbol_nam
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${MUTATING_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: ['new_name'],
|
||||
|
|
@ -593,8 +598,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". Omit if only one repo is indexed.',
|
||||
description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
service: {
|
||||
type: 'string',
|
||||
|
|
@ -690,7 +694,7 @@ Findings are deliberately NOT part of impact()'s traversal or the web schema —
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
@ -742,7 +746,7 @@ CONTRACT CAVEATS:
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: ['mode', 'target'],
|
||||
|
|
@ -766,7 +770,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
@ -784,7 +788,10 @@ Returns: tool nodes with their handler files and descriptions.`,
|
|||
type: 'object',
|
||||
properties: {
|
||||
tool: { type: 'string', description: 'Filter by tool name. Omit for all tools.' },
|
||||
repo: { type: 'string', description: 'Repository name or path.' },
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
|
|
@ -807,7 +814,7 @@ Returns routes that have both detected response keys AND consumers. Shows top-le
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
@ -833,7 +840,10 @@ Response shape is keyed on how many routes match, not on the data: exactly one m
|
|||
description:
|
||||
'Optional HTTP verb — GET, POST, PUT, PATCH, DELETE, etc. — to narrow a multi-verb route or file lookup to a single method. Returns an error if no matched route uses that verb.',
|
||||
},
|
||||
repo: { type: 'string', description: 'Repository name or path.' },
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
|
|
@ -948,8 +958,7 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil
|
|||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. Omit if only one repo is indexed.',
|
||||
description: `Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. ${CWD_AWARE_REPO_OMISSION}`,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
|
|||
|
|
@ -44,18 +44,32 @@ export const computeFileHashes = async (
|
|||
repoPath: string,
|
||||
relPaths: readonly string[],
|
||||
): Promise<Map<string, string>> => {
|
||||
const out = new Map<string, string>();
|
||||
const { hashes } = await computeFileHashesDetailed(repoPath, relPaths);
|
||||
return hashes;
|
||||
};
|
||||
|
||||
/** Like {@link computeFileHashes}, but keeps paths whose content could not be read. */
|
||||
export const computeFileHashesDetailed = async (
|
||||
repoPath: string,
|
||||
relPaths: readonly string[],
|
||||
): Promise<{ hashes: Map<string, string>; unreadable: string[] }> => {
|
||||
const hashes = new Map<string, string>();
|
||||
const unreadable: string[] = [];
|
||||
const BATCH = 100;
|
||||
for (const batch of chunk(relPaths, BATCH)) {
|
||||
const results = await Promise.all(
|
||||
batch.map(async (rel) => {
|
||||
const h = await computeFileHash(path.join(repoPath, rel));
|
||||
return h ? ([rel, h] as const) : null;
|
||||
return { rel, h };
|
||||
}),
|
||||
);
|
||||
for (const r of results) if (r) out.set(r[0], r[1]);
|
||||
for (const { rel, h } of results) {
|
||||
if (h) hashes.set(rel, h);
|
||||
else unreadable.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
unreadable.sort();
|
||||
return { hashes, unreadable };
|
||||
};
|
||||
|
||||
/** Result of comparing the current on-disk hashes against stored ones. */
|
||||
|
|
|
|||
|
|
@ -4,42 +4,32 @@ import path from 'path';
|
|||
import os from 'os';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { toZeroBasedLine } from '../core/ingestion/utils/line-base.js';
|
||||
import { GITNEXUS_MANAGED_PATH_EXCLUDES, isGitNexusManagedPath } from './gitnexus-managed-paths.js';
|
||||
|
||||
// Git utilities for repository detection, commit tracking, and diff analysis
|
||||
|
||||
const chompGitOutput = (value: Buffer): string => value.toString().replace(/\r?\n$/, '');
|
||||
const GIT_PATH_LIST_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* True when the working tree has uncommitted changes that analyze would
|
||||
* re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
|
||||
* analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
|
||||
* repo-local .agents/ mirror) so its own output never counts as dirty
|
||||
* (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
|
||||
* matching the .claude/ treatment, because the skill mirror writes across
|
||||
* .agents/skills/ and deeper paths. Conservative on any git failure. Shared
|
||||
* so `analyze`'s fast-path gate and `status`'s freshness report agree on what
|
||||
* "dirty" means.
|
||||
* re-index, even at a matching HEAD. Excludes GITNEXUS_MANAGED_PATHS so
|
||||
* GitNexus's own analyze output never counts as dirty (regression vs PR #1233
|
||||
* behavior); whole directory trees are excluded, not just their root entries,
|
||||
* because the skill mirror writes across .agents/skills/ and deeper paths.
|
||||
* Conservative on any git failure.
|
||||
*
|
||||
* This drives `analyze`'s up-to-date fast path. It is deliberately coarse:
|
||||
* a false "dirty" here costs only a hash diff that finds nothing. `status`
|
||||
* reaches for the per-file comparison in core/index-content-drift.ts instead,
|
||||
* because there the same false positive is a verdict the user cannot clear
|
||||
* (#3077), and falls back to this only when that comparison cannot run.
|
||||
*/
|
||||
export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'status',
|
||||
'--porcelain',
|
||||
'--',
|
||||
'.',
|
||||
':(exclude).gitnexus',
|
||||
':(exclude).gitnexus/**',
|
||||
':(exclude).claude',
|
||||
':(exclude).claude/**',
|
||||
':(exclude).cursor',
|
||||
':(exclude).cursor/**',
|
||||
':(exclude)AGENTS.md',
|
||||
':(exclude)CLAUDE.md',
|
||||
':(exclude).agents',
|
||||
':(exclude).agents/**',
|
||||
],
|
||||
['status', '--porcelain', '--', '.', ...GITNEXUS_MANAGED_PATH_EXCLUDES],
|
||||
{
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
|
|
@ -53,6 +43,84 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
const parsePorcelainPaths = (porcelain: string): string[] => {
|
||||
const paths = new Set<string>();
|
||||
const records = porcelain.split('\0');
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
if (record.length < 4) continue;
|
||||
|
||||
const status = record.slice(0, 2);
|
||||
paths.add(record.slice(3));
|
||||
|
||||
// In porcelain v1 `-z` mode, rename/copy source and destination paths are
|
||||
// separate NUL records (with no human-facing ` -> ` delimiter). Keep both:
|
||||
// either side may be present in the previous coverage set.
|
||||
if (status.includes('R') || status.includes('C')) {
|
||||
const pairedPath = records[++i];
|
||||
if (pairedPath) paths.add(pairedPath);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
};
|
||||
|
||||
const gitPathListExec = {
|
||||
stdio: ['ignore', 'pipe', 'ignore'] as ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8' as const,
|
||||
maxBuffer: GIT_PATH_LIST_MAX_BUFFER,
|
||||
};
|
||||
|
||||
const listHiddenIndexPaths = (repoPath: string): string[] => {
|
||||
const out = execFileSync('git', ['ls-files', '-v', '-z', '--'], {
|
||||
cwd: repoPath,
|
||||
windowsHide: true,
|
||||
...gitPathListExec,
|
||||
});
|
||||
const paths: string[] = [];
|
||||
for (const record of out.split('\0')) {
|
||||
if (record.length < 3 || record[1] !== ' ') continue;
|
||||
const tag = record[0];
|
||||
// `S` marks skip-worktree. With `-v`, an assume-unchanged entry's
|
||||
// ordinary tag is lower-cased (`H` -> `h`, `S` -> `s`, etc.).
|
||||
if (tag === 'S' || (tag >= 'a' && tag <= 'z')) paths.push(record.slice(2));
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repo-relative paths `git status` reports as dirty or untracked, using the
|
||||
* same managed-path excludes as {@link isWorkingTreeDirty}, plus tracked paths
|
||||
* whose assume-unchanged or skip-worktree bits can hide content changes from
|
||||
* porcelain. `null` means either query failed — callers must not treat that as
|
||||
* a clean tree.
|
||||
*/
|
||||
export const listWorkingTreeDirtyPaths = (repoPath: string): string[] | null => {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'-z',
|
||||
'--untracked-files=all',
|
||||
'--',
|
||||
'.',
|
||||
...GITNEXUS_MANAGED_PATH_EXCLUDES,
|
||||
],
|
||||
{ cwd: repoPath, windowsHide: true, ...gitPathListExec },
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
[...parsePorcelainPaths(out), ...listHiddenIndexPaths(repoPath)].filter(
|
||||
(rel) => !isGitNexusManagedPath(rel),
|
||||
),
|
||||
),
|
||||
];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
|
||||
* to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
|
||||
|
|
|
|||
53
gitnexus/src/storage/gitnexus-managed-paths.ts
Normal file
53
gitnexus/src/storage/gitnexus-managed-paths.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* The paths GitNexus itself writes during `analyze`.
|
||||
*
|
||||
* `analyze` rewrites the stats blocks in AGENTS.md/CLAUDE.md and refreshes the
|
||||
* agent skill mirrors as its final step — after it has recorded the per-file
|
||||
* hashes for the run. Counting its own output as a repository change makes
|
||||
* every completed run look immediately out of date, which is the regression
|
||||
* PR #1233 introduced and #1233's fix excluded these paths to prevent.
|
||||
*
|
||||
* Two freshness checks depend on this list agreeing: `isWorkingTreeDirty`
|
||||
* (analyze's up-to-date fast-path gate) and the per-file comparison behind
|
||||
* `status`. They used to hold separate copies of it, so a path added to one
|
||||
* silently became a permanent "stale" verdict in the other. One list, imported
|
||||
* by both.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Repository-root-relative. A directory entry covers everything beneath it;
|
||||
* a file entry matches only itself. Prefix collisions are NOT matches —
|
||||
* `.agentsrc` is an ordinary file, not part of the `.agents` tree.
|
||||
*/
|
||||
export const GITNEXUS_MANAGED_PATHS = [
|
||||
'.gitnexus',
|
||||
'.claude',
|
||||
'.cursor',
|
||||
'.agents',
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Git pathspecs excluding {@link GITNEXUS_MANAGED_PATHS} from a `git status`
|
||||
* run rooted at the repository. Patterns include `./` so they match only at
|
||||
* the repo root: a slash-free `:(exclude)AGENTS.md` would also drop
|
||||
* `docs/AGENTS.md`, which {@link isGitNexusManagedPath} does not treat as
|
||||
* managed. Both forms are emitted per entry: the root path itself, and `/**`
|
||||
* for directory contents.
|
||||
*/
|
||||
export const GITNEXUS_MANAGED_PATH_EXCLUDES: readonly string[] = GITNEXUS_MANAGED_PATHS.flatMap(
|
||||
(managed) => [`:(exclude,glob)./${managed}`, `:(exclude,glob)./${managed}/**`],
|
||||
);
|
||||
|
||||
/**
|
||||
* True when a repository-relative path is GitNexus's own output. Mirrors the
|
||||
* pathspec semantics above: root-relative, whole path segments only, so
|
||||
* neither `.agentsrc` nor a nested `subdir/.agents/` is treated as managed.
|
||||
*/
|
||||
export const isGitNexusManagedPath = (relPath: string): boolean => {
|
||||
const normalized = relPath.replace(/\\/g, '/');
|
||||
return GITNEXUS_MANAGED_PATHS.some(
|
||||
(managed) => normalized === managed || normalized.startsWith(`${managed}/`),
|
||||
);
|
||||
};
|
||||
|
|
@ -909,7 +909,10 @@ const registerRepoUnlocked = async (
|
|||
// falling back to `path.resolve` when the path doesn't exist.
|
||||
const canonicalInput = canonicalizePath(repoPath);
|
||||
|
||||
const entries = await readRegistry();
|
||||
// Mutating writes must not treat an unreadable/truncated registry as empty
|
||||
// (#3094): lenient `readRegistry()` returns `[]` on parse failure and would
|
||||
// replace the machine-wide file with only this entry. ENOENT stays empty.
|
||||
const entries = await readRegistryStrict();
|
||||
const existingIdx = entries.findIndex((e) => {
|
||||
// Canonicalise the STORED entry too so pre-canonicalisation
|
||||
// registries (written by older versions, or paths passed in a
|
||||
|
|
@ -1024,7 +1027,7 @@ const registerRepoUnlocked = async (
|
|||
// R9): re-derive THIS run's delta against the FRESHEST snapshot so a
|
||||
// concurrent change to the OTHER axis (a branch upsert vs a primary refresh)
|
||||
// survives instead of being clobbered by a stale entry-time view.
|
||||
const fresh = await readRegistry();
|
||||
const fresh = await readRegistryStrict();
|
||||
const freshIdx = fresh.findIndex((e) => {
|
||||
const a = canonicalizePath(e.path);
|
||||
return registryPathEquals(a, canonicalInput);
|
||||
|
|
@ -1469,6 +1472,27 @@ export const resolveRegistryEntry = (entries: RegistryEntry[], target: string):
|
|||
throw new RegistryNotFoundError(target, availableNames);
|
||||
};
|
||||
|
||||
/**
|
||||
* Name-only registry match (the name tier of {@link resolveRegistryEntry},
|
||||
* without path matching). Used by `group.yaml` member *values*, which are
|
||||
* registry aliases, not filesystem paths.
|
||||
*
|
||||
* Zero matches → `undefined` (caller treats as missing). One match → that
|
||||
* entry. Two or more → {@link RegistryAmbiguousTargetError}.
|
||||
*/
|
||||
export const findRegistryEntryByName = (
|
||||
entries: RegistryEntry[],
|
||||
name: string,
|
||||
): RegistryEntry | undefined => {
|
||||
const targetLower = name.toLowerCase();
|
||||
const nameMatches = entries.filter((e) => e.name.toLowerCase() === targetLower);
|
||||
if (nameMatches.length === 1) return nameMatches[0];
|
||||
if (nameMatches.length > 1) {
|
||||
throw new RegistryAmbiguousTargetError(name, nameMatches);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all registered repos from the global registry.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -284,6 +284,18 @@ export interface RepoMeta {
|
|||
* Map keys are repo-relative paths.
|
||||
*/
|
||||
fileHashes?: Record<string, string>;
|
||||
/**
|
||||
* Coverage policy used when `fileHashes` was recorded. `status` replays it
|
||||
* so analyze-time `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot make
|
||||
* a later default-cap walk drop a file the index actually covers.
|
||||
* `dirtyPaths` are covered files that were dirty vs HEAD at that moment —
|
||||
* status must re-hash those even after Git becomes clean (indexed-dirty then
|
||||
* restore). Absent on indexes written before this field.
|
||||
*/
|
||||
indexCoverage?: {
|
||||
maxFileSizeBytes: number;
|
||||
dirtyPaths?: string[];
|
||||
};
|
||||
/**
|
||||
* Set when a run finished but the persisted edge count came back far short
|
||||
* of what the pipeline produced — the B2 "refresh reports SUCCESS while the
|
||||
|
|
|
|||
|
|
@ -55,6 +55,51 @@ describe('group CLI', () => {
|
|||
expect(l.stdout).toContain('acme');
|
||||
});
|
||||
|
||||
it('sync exits nonzero with formatted copy when a member name is ambiguous', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-cli-amb-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(home, 'groups', 'g1'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'groups', 'g1', 'group.yaml'),
|
||||
`version: 1
|
||||
name: g1
|
||||
repos:
|
||||
demo/api: demo-api
|
||||
`,
|
||||
);
|
||||
const cloneA = path.join(home, 'clone-a');
|
||||
const cloneB = path.join(home, 'clone-b');
|
||||
fs.mkdirSync(path.join(cloneA, '.gitnexus'), { recursive: true });
|
||||
fs.mkdirSync(path.join(cloneB, '.gitnexus'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'registry.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'demo-api',
|
||||
path: cloneA,
|
||||
storagePath: path.join(cloneA, '.gitnexus'),
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'aaa',
|
||||
},
|
||||
{
|
||||
name: 'demo-api',
|
||||
path: cloneB,
|
||||
storagePath: path.join(cloneB, '.gitnexus'),
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'bbb',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const r = runGroupIn(home, ['sync', 'g1']);
|
||||
expect(r.status).not.toBe(0);
|
||||
// CLI logs JSON (pino): quotes around the group name are escaped in the byte stream.
|
||||
expect(`${r.stderr}${r.stdout}`).toMatch(/Did not sync group \\"g1\\"/);
|
||||
expect(`${r.stderr}${r.stdout}`).toContain('demo-api');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('test_create_with_invalid_name_fails', () => {
|
||||
const result = runGroup(['create', '../../evil']);
|
||||
expect(result.status).not.toBe(0);
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ describe('LocalBackend.callTool', () => {
|
|||
['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }],
|
||||
['context', { name: 'validate', file_path: 'src/auth.ts', file: 'src/login.ts' }],
|
||||
])('rejects conflicting %s aliases before repository resolution', async (method, params) => {
|
||||
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
|
||||
const resolveSpy = vi.spyOn(backend, 'selectToolRepository');
|
||||
|
||||
const result = await backend.callTool(method, params);
|
||||
|
||||
|
|
@ -434,7 +434,7 @@ describe('LocalBackend.callTool', () => {
|
|||
['context', { name: 'validate', file: ' ' }],
|
||||
['context', { name: 'validate', file: null }],
|
||||
])('rejects invalid %s aliases before repository resolution', async (method, params) => {
|
||||
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
|
||||
const resolveSpy = vi.spyOn(backend, 'selectToolRepository');
|
||||
|
||||
const result = await backend.callTool(method, params);
|
||||
|
||||
|
|
@ -443,7 +443,7 @@ describe('LocalBackend.callTool', () => {
|
|||
});
|
||||
|
||||
it('rejects a missing impact target before repository resolution', async () => {
|
||||
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
|
||||
const resolveSpy = vi.spyOn(backend, 'selectToolRepository');
|
||||
|
||||
const result = await backend.callTool('impact', { direction: 'upstream' });
|
||||
|
||||
|
|
@ -3381,12 +3381,351 @@ describe('LocalBackend.resolveRepo', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('throws for ambiguous repos without param', async () => {
|
||||
setupMultipleRepos();
|
||||
await backend.init();
|
||||
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
|
||||
'Multiple repositories indexed',
|
||||
);
|
||||
it('throws for ambiguous repos when cwd is outside every indexed path', async () => {
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project-sibling');
|
||||
|
||||
try {
|
||||
setupMultipleRepos();
|
||||
await backend.init();
|
||||
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
|
||||
'Multiple repositories indexed',
|
||||
);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to the deepest indexed repo containing cwd (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-outer-'));
|
||||
const nestedDir = path.join(outerDir, 'packages', 'nested');
|
||||
const cwdDir = path.join(nestedDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir);
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
},
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'nested',
|
||||
path: nestedDir,
|
||||
storagePath: path.join(nestedDir, '.gitnexus'),
|
||||
},
|
||||
]);
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
const resolved = await backend.selectToolRepository(undefined, undefined, {
|
||||
allowCwdDefault: true,
|
||||
});
|
||||
expect(resolved.repoPath).toBe(nestedDir);
|
||||
const explicit = await backend.resolveRepo('outer');
|
||||
expect(explicit.repoPath).toBe(outerDir);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes before accepting a cached cwd ancestor (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-stale-outer-'));
|
||||
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-stale-other-'));
|
||||
const nestedDir = path.join(outerDir, 'vendor', 'nested');
|
||||
const cwdDir = path.join(nestedDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir, otherDir);
|
||||
|
||||
const outerEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
};
|
||||
const nestedEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'nested',
|
||||
path: nestedDir,
|
||||
storagePath: path.join(nestedDir, '.gitnexus'),
|
||||
};
|
||||
const otherEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'other',
|
||||
path: otherDir,
|
||||
storagePath: path.join(otherDir, '.gitnexus'),
|
||||
};
|
||||
(listRegisteredRepos as any)
|
||||
.mockResolvedValueOnce([outerEntry, otherEntry])
|
||||
.mockResolvedValue([outerEntry, nestedEntry, otherEntry]);
|
||||
(getGitRoot as any).mockImplementation((value: string) => {
|
||||
const resolved = path.resolve(value);
|
||||
if (resolved === nestedDir || resolved.startsWith(`${nestedDir}${path.sep}`)) {
|
||||
return nestedDir;
|
||||
}
|
||||
if (resolved === outerDir || resolved.startsWith(`${outerDir}${path.sep}`)) {
|
||||
return outerDir;
|
||||
}
|
||||
if (resolved === otherDir || resolved.startsWith(`${otherDir}${path.sep}`)) {
|
||||
return otherDir;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
const resolved = await backend.selectToolRepository(undefined, undefined, {
|
||||
allowCwdDefault: true,
|
||||
});
|
||||
expect(resolved.repoPath).toBe(nestedDir);
|
||||
expect(listRegisteredRepos).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes a cached singleton before repo-less read dispatch (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-singleton-outer-'));
|
||||
const nestedDir = path.join(outerDir, 'packages', 'nested');
|
||||
const cwdDir = path.join(nestedDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir);
|
||||
|
||||
const outerEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
};
|
||||
const nestedEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'nested',
|
||||
path: nestedDir,
|
||||
storagePath: path.join(nestedDir, '.gitnexus'),
|
||||
};
|
||||
(listRegisteredRepos as any)
|
||||
.mockResolvedValueOnce([outerEntry])
|
||||
.mockResolvedValue([outerEntry, nestedEntry]);
|
||||
(getGitRoot as any).mockReturnValue(outerDir);
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
await backend.callTool('cypher', { statement: 'MATCH (n) RETURN n LIMIT 1' });
|
||||
|
||||
expect((executeParameterized as any).mock.calls.at(-1)?.[0]).toBe(
|
||||
path.join(nestedDir, '.gitnexus', 'lbug'),
|
||||
);
|
||||
expect(listRegisteredRepos).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes a cached singleton before enforcing repo-less rename safety (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rename-outer-'));
|
||||
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rename-other-'));
|
||||
const cwdDir = path.join(outerDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir, otherDir);
|
||||
|
||||
const outerEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
};
|
||||
const otherEntry = {
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'other',
|
||||
path: otherDir,
|
||||
storagePath: path.join(otherDir, '.gitnexus'),
|
||||
};
|
||||
(listRegisteredRepos as any)
|
||||
.mockResolvedValueOnce([outerEntry])
|
||||
.mockResolvedValue([outerEntry, otherEntry]);
|
||||
(getGitRoot as any).mockReturnValue(outerDir);
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
await expect(
|
||||
backend.callTool('rename', {
|
||||
symbol_name: 'oldName',
|
||||
new_name: 'newName',
|
||||
dry_run: false,
|
||||
}),
|
||||
).rejects.toThrow('Multiple repositories indexed');
|
||||
expect(listRegisteredRepos).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps explicit duplicate aliases on exact git-root disambiguation (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-alias-outer-'));
|
||||
const nestedDir = path.join(outerDir, 'packages', 'nested');
|
||||
const cwdDir = path.join(nestedDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir);
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'shared',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
},
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'shared',
|
||||
path: nestedDir,
|
||||
storagePath: path.join(nestedDir, '.gitnexus'),
|
||||
},
|
||||
]);
|
||||
(getGitRoot as any).mockReturnValue(outerDir);
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
const resolved = await backend.resolveRepo('shared');
|
||||
expect(resolved.repoPath).toBe(outerDir);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not cross a nested git boundary when git root shelling fails (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rootless-outer-'));
|
||||
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rootless-other-'));
|
||||
const nestedDir = path.join(outerDir, 'vendor', 'nested');
|
||||
const cwdDir = path.join(nestedDir, 'src');
|
||||
mkdirSync(path.join(nestedDir, '.git'), { recursive: true });
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir, otherDir);
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
},
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'other',
|
||||
path: otherDir,
|
||||
storagePath: path.join(otherDir, '.gitnexus'),
|
||||
},
|
||||
]);
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
|
||||
'Multiple repositories indexed',
|
||||
);
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps cwd routing opt-in for direct backend helpers (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-direct-outer-'));
|
||||
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-direct-other-'));
|
||||
const cwdDir = path.join(outerDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir, otherDir);
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
},
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'other',
|
||||
path: otherDir,
|
||||
storagePath: path.join(otherDir, '.gitnexus'),
|
||||
},
|
||||
]);
|
||||
(getGitRoot as any).mockReturnValue(outerDir);
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
await expect(backend.queryProcesses()).rejects.toThrow('Multiple repositories indexed');
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not default across an unindexed nested git boundary (#3073)', async () => {
|
||||
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-git-outer-'));
|
||||
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-git-other-'));
|
||||
const nestedDir = path.join(outerDir, 'vendor', 'nested');
|
||||
const cwdDir = path.join(nestedDir, 'src');
|
||||
mkdirSync(cwdDir, { recursive: true });
|
||||
duplicateFixtureDirs.push(outerDir, otherDir);
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'outer',
|
||||
path: outerDir,
|
||||
storagePath: path.join(outerDir, '.gitnexus'),
|
||||
},
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'other',
|
||||
path: otherDir,
|
||||
storagePath: path.join(otherDir, '.gitnexus'),
|
||||
},
|
||||
]);
|
||||
(getGitRoot as any).mockImplementation((value: string) => {
|
||||
const resolved = path.resolve(value);
|
||||
if (resolved === nestedDir || resolved.startsWith(`${nestedDir}${path.sep}`)) {
|
||||
return nestedDir;
|
||||
}
|
||||
if (resolved === outerDir || resolved.startsWith(`${outerDir}${path.sep}`)) {
|
||||
return outerDir;
|
||||
}
|
||||
if (resolved === otherDir || resolved.startsWith(`${otherDir}${path.sep}`)) {
|
||||
return otherDir;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
|
||||
|
||||
try {
|
||||
await backend.init();
|
||||
await expect(
|
||||
backend.selectToolRepository(undefined, undefined, { allowCwdDefault: true }),
|
||||
).rejects.toThrow('Multiple repositories indexed');
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps mutating rename explicit with multiple repos (#3073)', async () => {
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project/src');
|
||||
|
||||
try {
|
||||
setupMultipleRepos();
|
||||
await backend.init();
|
||||
await expect(
|
||||
backend.callTool('rename', {
|
||||
symbol_name: 'oldName',
|
||||
new_name: 'newName',
|
||||
dry_run: true,
|
||||
}),
|
||||
).rejects.toThrow('Multiple repositories indexed');
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves repo by name parameter', async () => {
|
||||
|
|
@ -4682,7 +5021,7 @@ describe('LocalBackend tool-staleness cache keying (#2655 review)', () => {
|
|||
lbugPath: `/r/.gitnexus/${path.join('branches', 'x', 'lbug')}`,
|
||||
lastCommit: 'BRANCHSHA',
|
||||
};
|
||||
vi.spyOn(backend, 'resolveRepo')
|
||||
vi.spyOn(backend, 'selectToolRepository')
|
||||
.mockResolvedValueOnce(flat as any)
|
||||
.mockResolvedValueOnce(branch as any);
|
||||
// The tool itself returns a plain (staleness-carryable) object.
|
||||
|
|
@ -4736,7 +5075,8 @@ describe('LocalBackend tool-staleness signal (#2655 review)', () => {
|
|||
lastCommit: 'HEADSHA',
|
||||
};
|
||||
|
||||
const stubResolve = () => vi.spyOn(backend, 'resolveRepo').mockResolvedValue(handle as any);
|
||||
const stubResolve = () =>
|
||||
vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(handle as any);
|
||||
|
||||
const stubStale = async () => {
|
||||
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
|
||||
|
|
|
|||
|
|
@ -928,3 +928,210 @@ describe('isWorkingTreeDirty', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listWorkingTreeDirtyPaths', () => {
|
||||
it('returns an empty list for a clean repository', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
|
||||
execFileSync(gitExecutable, ['add', '--', 'README.md'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns dirty source paths and omits GitNexus-managed writes', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
|
||||
execSync('git add -A && git commit -q -m init', { cwd: repo, stdio: 'ignore' });
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'src', 'foo.ts'), 'export const x = 1;');
|
||||
fs.mkdirSync(path.join(repo, '.gitnexus'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.gitnexus', 'meta.json'), '{}');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'x');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual(['src/foo.ts']);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('still reports nested lookalikes that are not GitNexus-managed', async () => {
|
||||
const { isWorkingTreeDirty, listWorkingTreeDirtyPaths } =
|
||||
await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, 'docs', 'AGENTS.md'), 'project notes');
|
||||
execFileSync(gitExecutable, ['add', '--', 'docs/AGENTS.md'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'docs', 'AGENTS.md'), 'edited notes');
|
||||
|
||||
expect(isWorkingTreeDirty(repo)).toBe(true);
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual(['docs/AGENTS.md']);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null (not an empty list) outside a git repository', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const dir = makeIsolatedTempDir('gn-nongit-paths-');
|
||||
try {
|
||||
expect(listWorkingTreeDirtyPaths(dir)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves non-ASCII, newline, and arrow-shaped filenames exactly', async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
const names = ['src/ä.ts'];
|
||||
if (process.platform !== 'win32') {
|
||||
names.push('src/a -> b.ts', 'src/line\nbreak.ts', 'src/tab\tname.ts', 'src/back\\slash.ts');
|
||||
}
|
||||
try {
|
||||
for (const name of names) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, name)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, name), 'before');
|
||||
}
|
||||
execFileSync(gitExecutable, ['add', '--', ...names], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
|
||||
for (const name of names) fs.writeFileSync(path.join(repo, name), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([...names].sort());
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'returns both paths for a rename without parsing filename text',
|
||||
async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
const before = 'src/before -> literal.ts';
|
||||
const after = 'src/after -> literal.ts';
|
||||
try {
|
||||
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, before), 'content');
|
||||
execFileSync(gitExecutable, ['add', '--', before], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['mv', '--', before, after], { cwd: repo, stdio: 'ignore' });
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([after, before].sort());
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'includes paths hidden by git update-index %s',
|
||||
async (flag) => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'hidden.ts'), 'before');
|
||||
execFileSync(gitExecutable, ['add', '--', 'hidden.ts'], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', flag, '--', 'hidden.ts'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'hidden.ts'), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toContain('hidden.ts');
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'omits GitNexus-managed paths hidden by git update-index %s',
|
||||
async (flag) => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
try {
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'before');
|
||||
execFileSync(gitExecutable, ['add', '--', 'README.md', 'AGENTS.md'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', flag, '--', 'AGENTS.md'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves exact unusual names hidden by index bits, including both bits',
|
||||
async () => {
|
||||
const { listWorkingTreeDirtyPaths } = await import('../../src/storage/git.js');
|
||||
const repo = makeIsolatedGitRepo();
|
||||
const names = ['ä.ts', 'a -> b.ts', 'tab\tname.ts', 'line\nbreak.ts'];
|
||||
try {
|
||||
for (const name of names) fs.writeFileSync(path.join(repo, name), 'before');
|
||||
execFileSync(gitExecutable, ['add', '--', ...names], { cwd: repo, stdio: 'ignore' });
|
||||
execFileSync(gitExecutable, ['commit', '-q', '-m', 'init'], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--assume-unchanged', '--', names[0]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[1]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--assume-unchanged', '--', names[2]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[2]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
execFileSync(gitExecutable, ['update-index', '--skip-worktree', '--', names[3]], {
|
||||
cwd: repo,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (const name of names) fs.writeFileSync(path.join(repo, name), 'after');
|
||||
|
||||
expect(listWorkingTreeDirtyPaths(repo)?.sort()).toEqual([...names].sort());
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ vi.mock('../../src/core/group/storage.js', () => ({
|
|||
listGroups: listGroupsMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/group/sync.js', () => ({ syncGroup: syncGroupMock }));
|
||||
vi.mock('../../src/core/group/sync.js', () => ({
|
||||
syncGroup: syncGroupMock,
|
||||
formatGroupSyncAmbiguousError: (err: Error) => err.message,
|
||||
}));
|
||||
vi.mock('../../src/core/git-staleness.js', () => ({ checkStaleness: vi.fn() }));
|
||||
|
||||
describe('GroupService — missing group error handling', () => {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,25 @@ links:
|
|||
expect(() => parseGroupConfig('version: 1\nname: test')).toThrow(/repos.*required/i);
|
||||
});
|
||||
|
||||
it('throws when a repos value is not a string (YAML number/boolean)', () => {
|
||||
expect(() =>
|
||||
parseGroupConfig(`version: 1
|
||||
name: test
|
||||
repos:
|
||||
app: 12
|
||||
`),
|
||||
).toThrow(/non-empty registry name string/);
|
||||
});
|
||||
|
||||
it('trims padded registry aliases so they match the registry name', () => {
|
||||
const config = parseGroupConfig(`version: 1
|
||||
name: test
|
||||
repos:
|
||||
app: " my-app "
|
||||
`);
|
||||
expect(config.repos.app).toBe('my-app');
|
||||
});
|
||||
|
||||
it('allows empty repos object (fresh group before first add)', () => {
|
||||
const yaml = `version: 1
|
||||
name: new-group
|
||||
|
|
|
|||
|
|
@ -444,4 +444,102 @@ describe('cross-impact', () => {
|
|||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('hints the yaml member path when --repo is the registry alias', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ci-alias-'));
|
||||
const groupDir = path.join(tmpDir, 'groups', 'g1');
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(groupDir, 'group.yaml'),
|
||||
`version: 1
|
||||
name: g1
|
||||
repos:
|
||||
demo/api: demo-api
|
||||
demo/web: demo-web
|
||||
`,
|
||||
);
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
try {
|
||||
const port: GroupToolPort = {
|
||||
resolveRepo: vi.fn(),
|
||||
impact: vi.fn(),
|
||||
query: vi.fn(),
|
||||
impactByUid: vi.fn(),
|
||||
context: vi.fn(),
|
||||
};
|
||||
const r = await runGroupImpact(
|
||||
{ port, gitnexusDir: tmpDir },
|
||||
{
|
||||
name: 'g1',
|
||||
repo: 'demo-api',
|
||||
target: 'Sym',
|
||||
direction: 'upstream',
|
||||
},
|
||||
);
|
||||
expect('error' in r).toBe(true);
|
||||
if ('error' in r) {
|
||||
expect(r.error).toContain('demo/api');
|
||||
expect(r.error).toMatch(/registry alias/i);
|
||||
expect(r.error).not.toContain('demo/web');
|
||||
}
|
||||
const mixedCase = await runGroupImpact(
|
||||
{ port, gitnexusDir: tmpDir },
|
||||
{
|
||||
name: 'g1',
|
||||
repo: 'Demo-API',
|
||||
target: 'Sym',
|
||||
direction: 'upstream',
|
||||
},
|
||||
);
|
||||
expect('error' in mixedCase).toBe(true);
|
||||
if ('error' in mixedCase) {
|
||||
expect(mixedCase.error).toContain('demo/api');
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('lists every member path that shares the same registry alias', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ci-alias-dup-'));
|
||||
const groupDir = path.join(tmpDir, 'groups', 'g1');
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(groupDir, 'group.yaml'),
|
||||
`version: 1
|
||||
name: g1
|
||||
repos:
|
||||
demo/api: shared
|
||||
demo/other: shared
|
||||
`,
|
||||
);
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
try {
|
||||
const port: GroupToolPort = {
|
||||
resolveRepo: vi.fn(),
|
||||
impact: vi.fn(),
|
||||
query: vi.fn(),
|
||||
impactByUid: vi.fn(),
|
||||
context: vi.fn(),
|
||||
};
|
||||
const r = await runGroupImpact(
|
||||
{ port, gitnexusDir: tmpDir },
|
||||
{
|
||||
name: 'g1',
|
||||
repo: 'shared',
|
||||
target: 'Sym',
|
||||
direction: 'upstream',
|
||||
},
|
||||
);
|
||||
expect('error' in r).toBe(true);
|
||||
if ('error' in r) {
|
||||
expect(r.error).toContain('demo/api');
|
||||
expect(r.error).toContain('demo/other');
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -127,4 +127,60 @@ describe('resolveBridgeNeighbors', () => {
|
|||
expect(rows).toEqual([]);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'registry alias as localRepo does not join contracts stamped with the member path',
|
||||
async () => {
|
||||
const consumer = makeContract({
|
||||
repo: 'demo/api',
|
||||
role: 'consumer',
|
||||
symbolUid: 'consumer-uid',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
symbolName: 'fetchUsers',
|
||||
contractId: 'http::GET::/api/users',
|
||||
confidence: 0.5,
|
||||
});
|
||||
const provider = makeContract({
|
||||
repo: 'demo/api',
|
||||
role: 'provider',
|
||||
symbolUid: 'provider-uid',
|
||||
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
|
||||
symbolName: 'getUsers',
|
||||
contractId: 'http::GET::/api/users',
|
||||
confidence: 0.9,
|
||||
});
|
||||
const link: CrossLink = {
|
||||
from: { repo: 'web', symbolUid: 'web-uid', symbolRef: consumer.symbolRef },
|
||||
to: { repo: 'demo/api', symbolUid: 'provider-uid', symbolRef: provider.symbolRef },
|
||||
type: 'http',
|
||||
contractId: 'http::GET::/api/users',
|
||||
matchType: 'manifest',
|
||||
confidence: 0.9,
|
||||
};
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [{ ...consumer, repo: 'web' }, provider],
|
||||
crossLinks: [link],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const aliasMiss = await resolveBridgeNeighbors(handle!, {
|
||||
localRepo: 'demo-api',
|
||||
uids: ['provider-uid'],
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(aliasMiss).toEqual([]);
|
||||
const pathHit = await resolveBridgeNeighbors(handle!, {
|
||||
localRepo: 'demo/api',
|
||||
uids: ['provider-uid'],
|
||||
direction: 'upstream',
|
||||
});
|
||||
expect(pathHit).toHaveLength(1);
|
||||
expect(pathHit[0]).toMatchObject({
|
||||
neighborRepo: 'web',
|
||||
matchType: 'manifest',
|
||||
});
|
||||
await closeBridgeDb(handle!);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const syncGroupMock = vi.fn<() => Promise<SyncResult>>();
|
|||
|
||||
vi.mock('../../../src/core/group/sync.js', () => ({
|
||||
syncGroup: (...args: unknown[]) => syncGroupMock(...(args as [])),
|
||||
formatGroupSyncAmbiguousError: (err: Error) => err.message,
|
||||
}));
|
||||
|
||||
const { GroupService } = await import('../../../src/core/group/service.js');
|
||||
|
|
|
|||
|
|
@ -54,10 +54,14 @@ vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
|
|||
getMaxResidentRepos: vi.fn(() => 5),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/storage/repo-manager.js', () => ({
|
||||
readRegistry: vi.fn(async () => []),
|
||||
readRegistryStrict: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRegistry: vi.fn(async () => []),
|
||||
readRegistryStrict: vi.fn(async () => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/core/group/extractors/http-route-extractor.js', () => ({
|
||||
HttpRouteExtractor: class {
|
||||
|
|
|
|||
273
gitnexus/test/unit/group/sync-registry-identity.test.ts
Normal file
273
gitnexus/test/unit/group/sync-registry-identity.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { syncGroup } from '../../../src/core/group/sync.js';
|
||||
import { RegistryAmbiguousTargetError } from '../../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../../helpers/test-db.js';
|
||||
import type { GroupConfig } from '../../../src/core/group/types.js';
|
||||
import { GroupService } from '../../../src/core/group/service.js';
|
||||
import type { GroupToolPort } from '../../../src/core/group/service.js';
|
||||
|
||||
const initLbugMock = vi.fn(async () => {});
|
||||
|
||||
vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
|
||||
initLbug: (...args: unknown[]) => initLbugMock(...args),
|
||||
executeParameterized: vi.fn(async () => []),
|
||||
pinRepo: vi.fn(() => () => {}),
|
||||
getMaxResidentRepos: vi.fn(() => 5),
|
||||
}));
|
||||
|
||||
const makeConfig = (repos: Record<string, string>, extra?: Partial<GroupConfig>): GroupConfig => ({
|
||||
version: 1,
|
||||
name: 'test',
|
||||
description: '',
|
||||
repos,
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: {
|
||||
http: false,
|
||||
graphql: false,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
includes: false,
|
||||
workspace_deps: false,
|
||||
},
|
||||
matching: {},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const row = (
|
||||
tmpHome: string,
|
||||
name: string,
|
||||
clone: string,
|
||||
): {
|
||||
name: string;
|
||||
path: string;
|
||||
storagePath: string;
|
||||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
} => {
|
||||
mkdirSync(path.join(tmpHome, 'repos', clone), { recursive: true });
|
||||
return {
|
||||
name,
|
||||
path: path.join(tmpHome, 'repos', clone),
|
||||
storagePath: path.join(tmpHome, 'repos', clone, '.gitnexus'),
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'abc123',
|
||||
};
|
||||
};
|
||||
|
||||
describe('syncGroup registry name identity', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
let registryPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
initLbugMock.mockReset();
|
||||
initLbugMock.mockResolvedValue(undefined);
|
||||
tmpHome = await createTempDir('gitnexus-sync-registry-id-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
registryPath = path.join(tmpHome.dbPath, 'registry.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('throws RegistryAmbiguousTargetError and does not rewrite group dir files', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
||||
|
||||
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g');
|
||||
await fs.mkdir(groupDir, { recursive: true });
|
||||
const contractsPath = path.join(groupDir, 'contracts.json');
|
||||
const prior = '{"contracts":[],"crossLinks":[],"marker":"keep"}\n';
|
||||
await fs.writeFile(contractsPath, prior);
|
||||
|
||||
await expect(syncGroup(makeConfig({ 'demo/api': 'demo-api' }), { groupDir })).rejects.toSatisfy(
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(RegistryAmbiguousTargetError);
|
||||
const amb = err as RegistryAmbiguousTargetError;
|
||||
expect(amb.matches).toHaveLength(2);
|
||||
expect(amb.matches.map((m) => m.path).sort()).toEqual([a.path, b.path].sort());
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(await fs.readFile(contractsPath, 'utf-8')).toBe(prior);
|
||||
await expect(fs.access(path.join(groupDir, 'bridge.lbug'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('records an unknown yaml value as missing and still extracts other members', async () => {
|
||||
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
|
||||
await fs.writeFile(registryPath, JSON.stringify([known]));
|
||||
|
||||
const result = await syncGroup(
|
||||
makeConfig({ 'app/backend': 'backend-repo', 'app/ghost': 'ghost' }),
|
||||
{ skipWrite: true },
|
||||
);
|
||||
|
||||
expect(result.missingRepos).toEqual(['app/ghost']);
|
||||
expect(result.unreadableRepos).toEqual([]);
|
||||
expect(result.repoSnapshots['app/backend']).toEqual({
|
||||
indexedAt: known.indexedAt,
|
||||
lastCommit: known.lastCommit,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats mixed missing and ambiguous names as a terminal ambiguity with no write', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
||||
|
||||
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g');
|
||||
await fs.mkdir(groupDir, { recursive: true });
|
||||
const contractsPath = path.join(groupDir, 'contracts.json');
|
||||
await fs.writeFile(contractsPath, '{"keep":true}');
|
||||
|
||||
await expect(
|
||||
syncGroup(makeConfig({ 'demo/api': 'demo-api', 'app/ghost': 'ghost' }), { groupDir }),
|
||||
).rejects.toBeInstanceOf(RegistryAmbiguousTargetError);
|
||||
|
||||
expect(await fs.readFile(contractsPath, 'utf-8')).toBe('{"keep":true}');
|
||||
});
|
||||
|
||||
it('injected resolveRepoHandle still bypasses default name matching', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
||||
|
||||
const result = await syncGroup(makeConfig({ 'demo/api': 'demo-api' }), {
|
||||
skipWrite: true,
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: 'injected',
|
||||
path: groupPath,
|
||||
repoPath: a.path,
|
||||
storagePath: a.storagePath,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
expect(result.unreadableRepos).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not treat a filesystem path yaml value as a registry hit', async () => {
|
||||
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
|
||||
await fs.writeFile(registryPath, JSON.stringify([known]));
|
||||
|
||||
const result = await syncGroup(makeConfig({ 'app/backend': known.path }), { skipWrite: true });
|
||||
|
||||
expect(result.missingRepos).toEqual(['app/backend']);
|
||||
expect(result.repoSnapshots['app/backend']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injected resolveRepoHandle plus workspace_deps does not throw on duplicate names', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
||||
|
||||
const result = await syncGroup(
|
||||
makeConfig(
|
||||
{ 'demo/api': 'demo-api' },
|
||||
{
|
||||
detect: {
|
||||
http: false,
|
||||
graphql: false,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
includes: false,
|
||||
workspace_deps: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
skipWrite: true,
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: 'injected',
|
||||
path: groupPath,
|
||||
repoPath: a.path,
|
||||
storagePath: a.storagePath,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
});
|
||||
|
||||
it('injected resolveRepoHandle plus workspace_deps still bypasses name lookup after extraction failure', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
||||
initLbugMock.mockRejectedValueOnce(new Error('init failed'));
|
||||
|
||||
const result = await syncGroup(
|
||||
makeConfig(
|
||||
{ 'demo/api': 'demo-api' },
|
||||
{
|
||||
detect: {
|
||||
http: false,
|
||||
graphql: false,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
includes: false,
|
||||
workspace_deps: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
skipWrite: true,
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: 'injected',
|
||||
path: groupPath,
|
||||
repoPath: a.path,
|
||||
storagePath: a.storagePath,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
expect(result.unreadableRepos).toEqual(['demo/api']);
|
||||
});
|
||||
|
||||
it('MCP groupSync returns { error } for an ambiguous registry name', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
await fs.writeFile(registryPath, JSON.stringify([a, b]));
|
||||
|
||||
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g1');
|
||||
await fs.mkdir(groupDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(groupDir, 'group.yaml'),
|
||||
`version: 1
|
||||
name: g1
|
||||
repos:
|
||||
demo/api: demo-api
|
||||
`,
|
||||
);
|
||||
|
||||
const port: GroupToolPort = {
|
||||
resolveRepo: vi.fn(),
|
||||
impact: vi.fn(),
|
||||
query: vi.fn(),
|
||||
impactByUid: vi.fn(),
|
||||
context: vi.fn(),
|
||||
};
|
||||
const svc = new GroupService(port);
|
||||
const payload = (await svc.groupSync({ name: 'g1' })) as { error?: string };
|
||||
|
||||
expect(payload.error).toBeDefined();
|
||||
expect(payload.error).toContain('demo-api');
|
||||
expect(payload.error).toContain(a.path);
|
||||
expect(payload.error).toContain(b.path);
|
||||
expect(payload.error).toMatch(/unique registry name/i);
|
||||
expect(payload.error).not.toMatch(/Pass the absolute path/);
|
||||
});
|
||||
});
|
||||
|
|
@ -66,10 +66,14 @@ vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
|
|||
getMaxResidentRepos: vi.fn(() => 5),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/storage/repo-manager.js', () => ({
|
||||
readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args),
|
||||
readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args),
|
||||
}));
|
||||
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args),
|
||||
readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Armed by the bridge-write-failure suite at the bottom of this file, `null`
|
||||
|
|
|
|||
|
|
@ -156,10 +156,14 @@ vi.mock('../../../src/core/lbug/sidecar-recovery.js', () => ({
|
|||
|
||||
// The registry read happens in syncGroup's else branch; resolveRepoHandle is
|
||||
// supplied, so an empty registry is fine (only the meta.json fallback reads it).
|
||||
vi.mock('../../../src/storage/repo-manager.js', () => ({
|
||||
readRegistry: vi.fn().mockResolvedValue([]),
|
||||
readRegistryStrict: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRegistry: vi.fn().mockResolvedValue([]),
|
||||
readRegistryStrict: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
const { syncGroup } = await import('../../../src/core/group/sync.js');
|
||||
const { closeLbug, getMaxResidentRepos } = await import('../../../src/core/lbug/pool-adapter.js');
|
||||
|
|
|
|||
404
gitnexus/test/unit/index-content-drift.test.ts
Normal file
404
gitnexus/test/unit/index-content-drift.test.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/**
|
||||
* Unit Tests: per-file index freshness (core/index-content-drift.ts)
|
||||
*
|
||||
* Issue #3077: `status` answered "is the index fresh?" with a repo-wide
|
||||
* `git status --porcelain` boolean, so a modified or untracked file the index
|
||||
* never reads pinned the verdict to "stale" — and because `analyze` cannot
|
||||
* commit or delete that file, the advice it printed could never clear it.
|
||||
*
|
||||
* These tests use real temporary directories rather than mocks: the whole
|
||||
* point of the helper is that it reuses analyze's own scan, so the ignore
|
||||
* rules and the large-file cap are exactly what the assertions are about.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import { detectIndexContentDrift } from '../../src/core/index-content-drift.js';
|
||||
import { walkRepositoryPaths } from '../../src/core/ingestion/filesystem-walker.js';
|
||||
import { computeFileHashes } from '../../src/storage/file-hash.js';
|
||||
import { listWorkingTreeDirtyPaths } from '../../src/storage/git.js';
|
||||
import {
|
||||
GITNEXUS_MANAGED_PATH_EXCLUDES,
|
||||
isGitNexusManagedPath,
|
||||
} from '../../src/storage/gitnexus-managed-paths.js';
|
||||
|
||||
const gitExecutable = (() => {
|
||||
if (process.platform !== 'win32') return 'git';
|
||||
try {
|
||||
return (
|
||||
execFileSync('where.exe', ['git'], { encoding: 'utf8' }).split(/\r?\n/).find(Boolean) ?? 'git'
|
||||
);
|
||||
} catch {
|
||||
return 'git';
|
||||
}
|
||||
})();
|
||||
|
||||
const isolatedTmpRoot = (() => {
|
||||
const root =
|
||||
process.platform === 'win32'
|
||||
? path.join(path.parse(os.tmpdir()).root, 'gitnexus-drift')
|
||||
: path.join(os.tmpdir(), 'gitnexus-drift');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
return root;
|
||||
})();
|
||||
|
||||
const createdRepos: string[] = [];
|
||||
|
||||
const makeRepo = (files: Record<string, string>): string => {
|
||||
const dir = fs.mkdtempSync(path.join(isolatedTmpRoot, 'repo-'));
|
||||
createdRepos.push(dir);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
/** Reproduce what `analyze` records in `meta.fileHashes` for a repository. */
|
||||
const recordCoverage = async (
|
||||
repoPath: string,
|
||||
walkOptions?: Parameters<typeof walkRepositoryPaths>[2],
|
||||
): Promise<Record<string, string>> => {
|
||||
const scanned = await walkRepositoryPaths(repoPath, undefined, walkOptions);
|
||||
const hashes = await computeFileHashes(
|
||||
repoPath,
|
||||
scanned.map((f) => f.path),
|
||||
);
|
||||
return Object.fromEntries(hashes);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
while (createdRepos.length > 0) {
|
||||
fs.rmSync(createdRepos.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('detectIndexContentDrift', () => {
|
||||
it('reports current when every covered file still matches disk', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
const drift = await detectIndexContentDrift(repo, recorded);
|
||||
|
||||
expect(drift).toEqual({ kind: 'current', coveredFileCount: Object.keys(recorded).length });
|
||||
});
|
||||
|
||||
it('stays current when a file the index does not cover is modified (#3077)', async () => {
|
||||
// `.lock` is an ignored extension, so the indexer never reads this file.
|
||||
// Under the old repo-wide dirty check its edit forced an unclearable
|
||||
// "stale" verdict on an index that was byte-current with its own coverage.
|
||||
const repo = makeRepo({
|
||||
'a.js': 'export const a = 1;\n',
|
||||
'toolingdir/state.lock': 'before\n',
|
||||
});
|
||||
const recorded = await recordCoverage(repo);
|
||||
expect(Object.keys(recorded)).not.toContain('toolingdir/state.lock');
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'toolingdir/state.lock'), 'after\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('stays current when an ignored directory changes', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.mkdirSync(path.join(repo, 'node_modules', 'left-pad'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repo, 'node_modules', 'left-pad', 'index.js'),
|
||||
'module.exports=1;\n',
|
||||
);
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('reports the covered file that changed', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n', 'b.js': 'export const b = 2;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'b.js'), 'export const b = 3;\n');
|
||||
|
||||
const drift = await detectIndexContentDrift(repo, recorded);
|
||||
expect(drift).toMatchObject({ kind: 'drifted', changed: ['b.js'], added: [], deleted: [] });
|
||||
});
|
||||
|
||||
it('reports a new coverable file as added rather than certifying the index', async () => {
|
||||
// The index is missing a file `analyze` would pick up, so "up-to-date"
|
||||
// would be a false all-clear even though every recorded hash matches.
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.writeFileSync(path.join(repo, 'new-source.js'), 'export const n = 1;\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({
|
||||
kind: 'drifted',
|
||||
added: ['new-source.js'],
|
||||
changed: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a removed covered file as deleted', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n', 'b.js': 'export const b = 2;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
|
||||
fs.rmSync(path.join(repo, 'b.js'));
|
||||
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({
|
||||
kind: 'drifted',
|
||||
deleted: ['b.js'],
|
||||
changed: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('clears back to current once the coverage set is re-recorded', async () => {
|
||||
// The loop the issue reports: `analyze` ran, reported success, and the
|
||||
// verdict did not move. Re-recording coverage must settle the verdict.
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const stale = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, 'notes.txt'), 'scratch\n');
|
||||
expect(await detectIndexContentDrift(repo, stale)).toMatchObject({ kind: 'drifted' });
|
||||
|
||||
const reanalyzed = await recordCoverage(repo);
|
||||
|
||||
expect(await detectIndexContentDrift(repo, reanalyzed)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it("ignores GitNexus's own analyze output on both sides", async () => {
|
||||
// analyze rewrites AGENTS.md/CLAUDE.md after recording hashes. Counting
|
||||
// them made a freshly indexed repo report itself stale: absent from the
|
||||
// first run's coverage, then rewritten on every run after that.
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const firstRun = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'stats block\n');
|
||||
fs.writeFileSync(path.join(repo, 'CLAUDE.md'), 'stats block\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, firstRun)).toMatchObject({ kind: 'current' });
|
||||
|
||||
const secondRun = await recordCoverage(repo);
|
||||
expect(Object.keys(secondRun)).toContain('AGENTS.md');
|
||||
fs.writeFileSync(path.join(repo, 'AGENTS.md'), 'refreshed stats block\n');
|
||||
|
||||
expect(await detectIndexContentDrift(repo, secondRun)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('is unmeasurable, not current, when metadata carries no file hashes', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
|
||||
expect(await detectIndexContentDrift(repo, undefined)).toEqual({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
expect(await detectIndexContentDrift(repo, {})).toEqual({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
});
|
||||
|
||||
it('is unmeasurable when the repository scan throws', async () => {
|
||||
const drift = await detectIndexContentDrift('/no-such-gitnexus-drift-repo', {
|
||||
'a.js': 'deadbeef',
|
||||
});
|
||||
expect(drift).toEqual({ kind: 'unmeasurable', reason: 'scan-failed' });
|
||||
});
|
||||
|
||||
it('replays a recorded max-file-size so a later default cap cannot drop coverage', async () => {
|
||||
// `.bin` is a hardcoded ignore; a large source file is what analyze would
|
||||
// actually hash once `--max-file-size` / GITNEXUS_MAX_FILE_SIZE is raised.
|
||||
const raisedCap = 1024 * 1024;
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
fs.writeFileSync(path.join(repo, 'payload.js'), Buffer.alloc(700 * 1024, 1));
|
||||
const recorded = await recordCoverage(repo, { maxFileSizeBytes: raisedCap, quiet: true });
|
||||
expect(Object.keys(recorded)).toContain('payload.js');
|
||||
|
||||
const withPolicy = await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: raisedCap,
|
||||
});
|
||||
expect(withPolicy).toMatchObject({ kind: 'current' });
|
||||
|
||||
// No persisted policy (indexes from before `indexCoverage`): the file is
|
||||
// still on disk and hashed, so a later default cap must not call it deleted.
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({ kind: 'current' });
|
||||
});
|
||||
|
||||
it('treats a covered file that can no longer be read as changed, not current', async () => {
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
return;
|
||||
}
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
const recorded = await recordCoverage(repo);
|
||||
const target = path.join(repo, 'a.js');
|
||||
fs.chmodSync(target, 0);
|
||||
try {
|
||||
expect(await detectIndexContentDrift(repo, recorded)).toMatchObject({
|
||||
kind: 'drifted',
|
||||
changed: ['a.js'],
|
||||
});
|
||||
} finally {
|
||||
fs.chmodSync(target, 0o644);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-hashes a path that was dirty at index time even after Git is clean', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '.'], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-m', 'i'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
fs.writeFileSync(path.join(repo, 'a.js'), 'export const a = 2;\n');
|
||||
const recorded = await recordCoverage(repo);
|
||||
execFileSync(gitExecutable, ['checkout', '--', 'a.js'], { cwd: repo });
|
||||
|
||||
const skipped = await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
});
|
||||
expect(skipped).toMatchObject({ kind: 'current' });
|
||||
|
||||
const restored = await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: ['a.js'],
|
||||
});
|
||||
expect(restored).toMatchObject({ kind: 'drifted', changed: ['a.js'] });
|
||||
});
|
||||
|
||||
it.each(['--assume-unchanged', '--skip-worktree'])(
|
||||
'does not let git update-index %s hide covered-file drift',
|
||||
async (flag) => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init', '-q'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '--', 'a.js'], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
const recorded = await recordCoverage(repo);
|
||||
execFileSync(gitExecutable, ['update-index', flag, '--', 'a.js'], { cwd: repo });
|
||||
fs.writeFileSync(path.join(repo, 'a.js'), 'export const a = 2;\n');
|
||||
const listed = listWorkingTreeDirtyPaths(repo);
|
||||
expect(listed).not.toBeNull();
|
||||
expect(listed).toContain('a.js');
|
||||
|
||||
expect(
|
||||
await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
}),
|
||||
).toMatchObject({ kind: 'drifted', changed: ['a.js'] });
|
||||
},
|
||||
);
|
||||
|
||||
it('hashes the full intersection when the Git path query fails', async () => {
|
||||
const repo = makeRepo({ 'a.js': 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init', '-q'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '--', 'a.js'], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
const recorded = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, 'a.js'), 'export const a = 2;\n');
|
||||
|
||||
const savedPath = process.env.PATH;
|
||||
try {
|
||||
process.env.PATH = '';
|
||||
expect(listWorkingTreeDirtyPaths(repo)).toBeNull();
|
||||
expect(
|
||||
await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
}),
|
||||
).toMatchObject({ kind: 'drifted', changed: ['a.js'] });
|
||||
} finally {
|
||||
process.env.PATH = savedPath;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(process.platform === 'win32' ? ['ä.js'] : ['ä.js', 'a -> b.js', 'line\nbreak.js'])(
|
||||
'detects drift for porcelain-sensitive filename %j',
|
||||
async (fileName) => {
|
||||
const repo = makeRepo({ [fileName]: 'export const a = 1;\n' });
|
||||
execFileSync(gitExecutable, ['init', '-q'], { cwd: repo });
|
||||
execFileSync(gitExecutable, ['add', '--', fileName], { cwd: repo });
|
||||
execFileSync(
|
||||
gitExecutable,
|
||||
['-c', 'user.email=t@t.test', '-c', 'user.name=t', 'commit', '-q', '-m', 'init'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
const recorded = await recordCoverage(repo);
|
||||
fs.writeFileSync(path.join(repo, fileName), 'export const a = 2;\n');
|
||||
|
||||
expect(
|
||||
await detectIndexContentDrift(repo, recorded, {
|
||||
maxFileSizeBytes: 512 * 1024,
|
||||
dirtyPaths: [],
|
||||
}),
|
||||
).toMatchObject({ kind: 'drifted', changed: [fileName] });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isGitNexusManagedPath', () => {
|
||||
it('matches managed files and whole managed trees', () => {
|
||||
expect(isGitNexusManagedPath('AGENTS.md')).toBe(true);
|
||||
expect(isGitNexusManagedPath('CLAUDE.md')).toBe(true);
|
||||
expect(isGitNexusManagedPath('.agents/skills/gitnexus-area-auth/SKILL.md')).toBe(true);
|
||||
expect(isGitNexusManagedPath('.gitnexus/meta.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match prefix collisions or nested lookalikes', () => {
|
||||
// Same boundaries the `:(exclude)` pathspecs enforce for isWorkingTreeDirty.
|
||||
expect(isGitNexusManagedPath('.agentsrc')).toBe(false);
|
||||
expect(isGitNexusManagedPath('.claudefoo')).toBe(false);
|
||||
expect(isGitNexusManagedPath('subdir/.agents/x')).toBe(false);
|
||||
expect(isGitNexusManagedPath('docs/AGENTS.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits root-anchored recursive pathspecs for every managed path', () => {
|
||||
expect(GITNEXUS_MANAGED_PATH_EXCLUDES).toContain(':(exclude,glob)./AGENTS.md');
|
||||
expect(GITNEXUS_MANAGED_PATH_EXCLUDES).toContain(':(exclude,glob)./.agents');
|
||||
expect(GITNEXUS_MANAGED_PATH_EXCLUDES).toContain(':(exclude,glob)./.agents/**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkRepositoryPaths quiet option', () => {
|
||||
const savedMaxFileSize = process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const savedProgressActive = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedMaxFileSize === undefined) delete process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
else process.env.GITNEXUS_MAX_FILE_SIZE = savedMaxFileSize;
|
||||
if (savedProgressActive === undefined) delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
|
||||
else process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = savedProgressActive;
|
||||
});
|
||||
|
||||
it('suppresses the large-file notice so read-only callers stay silent', async () => {
|
||||
const repo = makeRepo({ 'big.js': `// ${'x'.repeat(4096)}\n` });
|
||||
process.env.GITNEXUS_MAX_FILE_SIZE = '1'; // 1KB cap — big.js is skipped
|
||||
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1'; // routes the notice to console.warn
|
||||
|
||||
const warnings: unknown[][] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => void warnings.push(args);
|
||||
try {
|
||||
const noisy = await walkRepositoryPaths(repo);
|
||||
const noisyCount = warnings.length;
|
||||
warnings.length = 0;
|
||||
const quiet = await walkRepositoryPaths(repo, undefined, { quiet: true });
|
||||
|
||||
expect(noisyCount).toBeGreaterThan(0);
|
||||
expect(warnings).toEqual([]);
|
||||
expect(quiet).toEqual(noisy);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -66,6 +66,7 @@ vi.mock('../../src/storage/git.js', () => ({
|
|||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
getGitRoot: vi.fn((p: string) => p),
|
||||
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
||||
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
import { listCommand } from '../../src/cli/list.js';
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ function createBackend(repos = REPOS) {
|
|||
repoPath: repo ?? repos[0]?.path,
|
||||
lastCommit: 'a'.repeat(40),
|
||||
})),
|
||||
selectToolRepository: vi.fn().mockImplementation(async (repo?: string) => ({
|
||||
name: repos.find((entry) => entry.path === repo)?.name ?? repo ?? repos[0]?.name,
|
||||
repoPath: repo ?? repos[0]?.path,
|
||||
lastCommit: 'a'.repeat(40),
|
||||
})),
|
||||
getContext: vi.fn().mockReturnValue(null),
|
||||
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
|
||||
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
|
||||
|
|
@ -138,6 +143,24 @@ describe('MCP repository policy', () => {
|
|||
expect(backend.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps restricted schemas explicit when a multi-repo allowlist listing shrinks', async () => {
|
||||
const backend = createBackend();
|
||||
const policy = await createMcpRepositoryPolicy(backend, {
|
||||
GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Beta',
|
||||
});
|
||||
const alpha = REPOS[0];
|
||||
if (!alpha) throw new Error('Alpha fixture is required');
|
||||
vi.mocked(backend.listRepos).mockResolvedValue([{ ...alpha }]);
|
||||
|
||||
await expect(policy.toolSchemaRepoRequirements(backend)).resolves.toEqual({
|
||||
readOnlyRequiresRepo: true,
|
||||
mutatingRequiresRepo: true,
|
||||
});
|
||||
await expect(
|
||||
policy.scopeBackend(backend).callTool('query', { search_query: 'auth' }),
|
||||
).rejects.toThrow(/explicit repo.*multiple repositories are allowed/i);
|
||||
});
|
||||
|
||||
it('fails startup when the default is outside the allowlist after canonical resolution', async () => {
|
||||
const backend = createBackend();
|
||||
await expect(
|
||||
|
|
@ -199,6 +222,7 @@ describe('MCP repository policy', () => {
|
|||
const scoped = policy.scopeBackend(backend);
|
||||
|
||||
await expect(scoped.resolveRepo('Beta')).rejects.toThrow(/not available/i);
|
||||
await expect(scoped.selectToolRepository('Beta')).rejects.toThrow(/not available/i);
|
||||
await expect(scoped.readGroupStatusResource('portfolio')).rejects.toThrow(
|
||||
/group.*unavailable/i,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import { readRegistry, readRegistryStrict } from '../../src/storage/repo-manager.js';
|
||||
import { readRegistry, readRegistryStrict, registerRepo } from '../../src/storage/repo-manager.js';
|
||||
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
import { syncGroup } from '../../src/core/group/sync.js';
|
||||
|
|
@ -116,6 +116,20 @@ describe('readRegistryStrict', () => {
|
|||
await expect(readRegistryStrict()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('registerRepo refuses to overwrite a truncated registry with a single entry', async () => {
|
||||
const prior = '{"truncated": ';
|
||||
await fs.writeFile(registryPath, prior);
|
||||
await expect(
|
||||
registerRepo('/repos/one', {
|
||||
repoPath: '/repos/one',
|
||||
lastCommit: 'abc',
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
stats: {},
|
||||
}),
|
||||
).rejects.toThrow('registry is corrupt');
|
||||
expect(await fs.readFile(registryPath, 'utf-8')).toBe(prior);
|
||||
});
|
||||
|
||||
it('throws when a row is missing the fields the resolver needs', async () => {
|
||||
// `[{}]` is a JSON array, so an array-shape check alone waved it through.
|
||||
// Every configured repo then failed to resolve and landed in missingRepos;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
adoptFlatBranchLabel,
|
||||
listRegisteredRepos,
|
||||
resolveRegistryEntry,
|
||||
findRegistryEntryByName,
|
||||
canonicalizePath,
|
||||
registryPathEquals,
|
||||
cloneDirBelongsToEntry,
|
||||
|
|
@ -1535,6 +1536,13 @@ describe('resolveRegistryEntry (#664)', () => {
|
|||
expect(resolveRegistryEntry(entries, 'Website')).toBe(entries[2]);
|
||||
});
|
||||
|
||||
it('findRegistryEntryByName is name-only: a filesystem path is a miss, not a path-tier hit', () => {
|
||||
expect(findRegistryEntryByName(entries, pathA)).toBeUndefined();
|
||||
expect(findRegistryEntryByName(entries, 'website')).toBe(entries[2]);
|
||||
expect(findRegistryEntryByName(entries, 'WEBSITE')).toBe(entries[2]);
|
||||
expect(() => findRegistryEntryByName(entries, 'app')).toThrow(RegistryAmbiguousTargetError);
|
||||
});
|
||||
|
||||
it('path match is case-insensitive on Windows only', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
// On POSIX, a differently-cased path must NOT match. Verify by
|
||||
|
|
|
|||
|
|
@ -396,7 +396,10 @@ describe('readResource', () => {
|
|||
});
|
||||
const result = await readResource('gitnexus://repos', backend);
|
||||
expect(result).toContain('Multiple repos indexed');
|
||||
expect(result).toContain('repo parameter');
|
||||
expect(result).toContain('process.cwd()');
|
||||
expect(result).toContain('unindexed nested Git checkout');
|
||||
expect(result).toContain('mutating tools without an MCP default');
|
||||
expect(result).toContain('pass repo explicitly');
|
||||
// The example must use a registered tool name, not the unregistered
|
||||
// `gitnexus_search` / `gitnexus_*` prefix (#2059).
|
||||
// #2175: advertise the renamed param, not the legacy "query" key.
|
||||
|
|
|
|||
|
|
@ -359,6 +359,52 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('--repair-fts applies analyze --name without a full re-index', async () => {
|
||||
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
|
||||
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
||||
initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
|
||||
createSearchFTSIndexes: vi.fn(async () => []),
|
||||
verifySearchFTSIndexes: vi.fn(async () => []),
|
||||
}));
|
||||
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
||||
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
ensureGitNexusIgnored: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-name-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-repair-name-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const seeded: RepoMeta = {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: 'abc123',
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: { files: 1, nodes: 1, edges: 1 },
|
||||
};
|
||||
await saveMeta(storagePath, seeded);
|
||||
const { registerRepo, readRegistry } = await import('../../src/storage/repo-manager.js');
|
||||
await registerRepo(tmpRepo.dbPath, seeded, { name: 'old' });
|
||||
await createPlaceholderGraphStore(lbugPath);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ repairFts: true, registryName: 'new' },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
expect(result.ftsRepairedOnly).toBe(true);
|
||||
expect((await readRegistry())[0].name).toBe('new');
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('--repair-fts backfills a full capabilities object when the existing meta predates the field entirely (#2767)', async () => {
|
||||
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
|
||||
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
loadMeta,
|
||||
registerRepo,
|
||||
saveMeta,
|
||||
readRegistry,
|
||||
RegistryNameCollisionError,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js';
|
||||
|
|
@ -90,6 +92,197 @@ describe('run-analyze module', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('applies analyze --name on the already-up-to-date path without --force', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-name-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const currentCommit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
|
||||
const meta: RepoMeta = {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
schemaFingerprint: SCHEMA_FINGERPRINT,
|
||||
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
|
||||
runnerIdentity: currentRunnerIdentity(),
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(tmpRepo.dbPath, meta, { name: 'old' });
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ registryName: 'new' },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(result.repoName).toBe('new');
|
||||
const entries = await readRegistry();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].name).toBe('new');
|
||||
const agents = await fs.readFile(path.join(tmpRepo.dbPath, 'AGENTS.md'), 'utf-8');
|
||||
expect(agents).toContain('**new**');
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('repeating the same --name on the fast path is a no-op, not an error', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-name-repeat-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-repeat-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const currentCommit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
|
||||
const meta: RepoMeta = {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
schemaFingerprint: SCHEMA_FINGERPRINT,
|
||||
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
|
||||
runnerIdentity: currentRunnerIdentity(),
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(tmpRepo.dbPath, meta, { name: 'kept' });
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ registryName: 'kept' },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect((await readRegistry())[0].name).toBe('kept');
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('fast-path --name still collides when another path already owns the alias', async () => {
|
||||
const tmpA = await createTempDir('gitnexus-run-analyze-fast-name-col-a-');
|
||||
const tmpB = await createTempDir('gitnexus-run-analyze-fast-name-col-b-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-col-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
for (const tmp of [tmpA, tmpB]) {
|
||||
execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmp.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
const commitB = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpB.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const metaA: RepoMeta = {
|
||||
repoPath: tmpA.dbPath,
|
||||
lastCommit: 'aaaa',
|
||||
indexedAt: new Date().toISOString(),
|
||||
schemaFingerprint: SCHEMA_FINGERPRINT,
|
||||
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
|
||||
runnerIdentity: currentRunnerIdentity(),
|
||||
};
|
||||
await registerRepo(tmpA.dbPath, metaA, { name: 'new' });
|
||||
|
||||
const { storagePath } = getStoragePaths(tmpB.dbPath);
|
||||
const metaB: RepoMeta = {
|
||||
repoPath: tmpB.dbPath,
|
||||
lastCommit: commitB,
|
||||
indexedAt: new Date().toISOString(),
|
||||
schemaFingerprint: SCHEMA_FINGERPRINT,
|
||||
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
|
||||
runnerIdentity: currentRunnerIdentity(),
|
||||
};
|
||||
await saveMeta(storagePath, metaB);
|
||||
await registerRepo(tmpB.dbPath, metaB, { name: 'old' });
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await expect(
|
||||
runFullAnalysis(tmpB.dbPath, { registryName: 'new' }, { onProgress: () => {} }),
|
||||
).rejects.toBeInstanceOf(RegistryNameCollisionError);
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpA.cleanup();
|
||||
await tmpB.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('plain fast path does not call registerRepo when --name is absent', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-no-name-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-no-name-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const currentCommit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
|
||||
const meta: RepoMeta = {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
schemaFingerprint: SCHEMA_FINGERPRINT,
|
||||
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
|
||||
runnerIdentity: currentRunnerIdentity(),
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(tmpRepo.dbPath, meta, { name: 'original' });
|
||||
|
||||
const registerSpy = vi.spyOn(
|
||||
await import('../../src/storage/repo-manager.js'),
|
||||
'registerRepo',
|
||||
);
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} });
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(registerSpy).not.toHaveBeenCalled();
|
||||
expect((await readRegistry())[0].name).toBe('original');
|
||||
registerSpy.mockRestore();
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('resumes a matching embedding checkpoint instead of taking the clean fast path', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-embedding-checkpoint-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-embedding-checkpoint-home-');
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ function createMockBackend(overrides: Record<string, any> = {}): any {
|
|||
resolveRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
||||
selectToolRepository: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
|
||||
getContext: vi.fn().mockReturnValue(null),
|
||||
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
|
||||
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
|
||||
|
|
@ -105,12 +108,13 @@ describe('createMCPServer', () => {
|
|||
await server.close();
|
||||
}
|
||||
});
|
||||
it('requires repo in repo-scoped tool schemas when multiple repos are visible', async () => {
|
||||
it('requires repo in repo-scoped tool schemas when cwd cannot resolve multiple repos', async () => {
|
||||
const backend = createMockBackend({
|
||||
listRepos: vi.fn().mockResolvedValue([
|
||||
{ name: 'alpha', path: '/tmp/alpha' },
|
||||
{ name: 'beta', path: '/tmp/beta' },
|
||||
]),
|
||||
selectToolRepository: vi.fn().mockRejectedValue(new Error('Multiple repositories indexed')),
|
||||
});
|
||||
const server = createMCPServer(backend);
|
||||
const client = new Client({ name: 'multi-repo-client', version: '0.0.0' });
|
||||
|
|
@ -133,6 +137,43 @@ describe('createMCPServer', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps repo optional when cwd resolves one of multiple visible repos', async () => {
|
||||
const backend = createMockBackend({
|
||||
listRepos: vi.fn().mockResolvedValue([
|
||||
{ name: 'alpha', path: '/tmp/alpha' },
|
||||
{ name: 'beta', path: '/tmp/beta' },
|
||||
]),
|
||||
selectToolRepository: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ name: 'alpha', repoPath: '/tmp/alpha', lastCommit: 'abc' }),
|
||||
});
|
||||
const server = createMCPServer(backend);
|
||||
const client = new Client({ name: 'cwd-repo-client', version: '0.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
try {
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
const tools = await client.listTools();
|
||||
const context = tools.tools.find((tool) => tool.name === 'context');
|
||||
const rename = tools.tools.find((tool) => tool.name === 'rename');
|
||||
|
||||
expect(context?.inputSchema.required).not.toContain('repo');
|
||||
expect(rename?.inputSchema.required).toContain('repo');
|
||||
const response = await client.callTool({ name: 'context', arguments: { name: 'Example' } });
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(backend.callTool).toHaveBeenCalledWith('context', { name: 'Example' });
|
||||
expect(backend.listRepos).toHaveBeenCalledTimes(1);
|
||||
expect(backend.selectToolRepository).toHaveBeenCalledTimes(1);
|
||||
expect(backend.selectToolRepository).toHaveBeenCalledWith(undefined, undefined, {
|
||||
allowCwdDefault: true,
|
||||
refreshRegistry: false,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps repo optional when a default repo is configured', async () => {
|
||||
const backend = createMockBackend({
|
||||
listRepos: vi.fn().mockResolvedValue([
|
||||
|
|
|
|||
279
gitnexus/test/unit/status-content-drift.test.ts
Normal file
279
gitnexus/test/unit/status-content-drift.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Unit Tests: `status` freshness verdict from per-file drift (#3077)
|
||||
*
|
||||
* The reported defect was a verdict nobody could clear: any modified or
|
||||
* untracked file in the working tree — including files the index never reads —
|
||||
* made `status` print "stale (re-run gitnexus analyze)", and running `analyze`
|
||||
* left it unchanged. These tests pin the new decision order: the per-file
|
||||
* comparison decides when it can run, and the repo-wide dirty flag survives
|
||||
* only as the fallback for metadata written before `fileHashes` existed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { runnerIdentity } = vi.hoisted(() => ({
|
||||
runnerIdentity: {
|
||||
schemaVersion: 4 as const,
|
||||
runtime: {
|
||||
executablePath: '/usr/bin/node',
|
||||
version: 'v22.0.0',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
modulesAbi: '127',
|
||||
libc: 'glibc:2.39',
|
||||
},
|
||||
cliVersion: '1.6.10',
|
||||
invokedArtifact: { path: '/opt/gitnexus/dist/cli/index.js', digest: 'sha256:entry' },
|
||||
build: {
|
||||
kind: 'distribution' as const,
|
||||
rootPath: '/opt/gitnexus/dist',
|
||||
canonicalization: 'gitnexus-analyzer-build-v2' as const,
|
||||
digest: 'sha256:build',
|
||||
},
|
||||
dependencyRuntime: {
|
||||
manifestPath: '/opt/gitnexus/package.json',
|
||||
lockfilePath: '/opt/package-lock.json',
|
||||
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4' as const,
|
||||
packageCount: 42,
|
||||
artifactCount: 12,
|
||||
digest: 'sha256:dependencies',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn(),
|
||||
findRepo: vi.fn(),
|
||||
getStoragePaths: vi.fn((repoPath: string) => ({
|
||||
storagePath: `${repoPath}/.gitnexus`,
|
||||
lbugPath: `${repoPath}/.gitnexus/lbug`,
|
||||
metaPath: `${repoPath}/.gitnexus/meta.json`,
|
||||
})),
|
||||
loadMeta: vi.fn(),
|
||||
hasKuzuIndex: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/analyzer-identity.js', () => ({
|
||||
resolveAnalyzerRunnerIdentity: vi.fn(() => runnerIdentity),
|
||||
analyzerRunnerIdentitiesEqual: vi.fn((indexed: unknown, current: unknown) => indexed === current),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getCurrentCommit: vi.fn().mockReturnValue('headsha0'),
|
||||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
getGitRoot: vi.fn((p: string) => p),
|
||||
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
||||
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/index-content-drift.js', () => ({
|
||||
detectIndexContentDrift: vi.fn(),
|
||||
}));
|
||||
|
||||
import { statusCommand } from '../../src/cli/status.js';
|
||||
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
import { findRepo } from '../../src/storage/repo-manager.js';
|
||||
import { getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js';
|
||||
import { detectIndexContentDrift } from '../../src/core/index-content-drift.js';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
const repoWithCoverage = {
|
||||
repoPath: '/repo',
|
||||
storagePath: '/repo/.gitnexus',
|
||||
lbugPath: '/repo/.gitnexus/lbug',
|
||||
metaPath: '/repo/.gitnexus/meta.json',
|
||||
meta: {
|
||||
repoPath: '/repo',
|
||||
lastCommit: 'headsha0',
|
||||
indexedAt: '2026-08-28T12:00:00.000Z',
|
||||
branch: 'main',
|
||||
runnerIdentity,
|
||||
fileHashes: { 'a.js': 'sha-a' },
|
||||
scopeExtractionReceipt: 1 as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(findRepo as any).mockResolvedValue(repoWithCoverage);
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
(isWorkingTreeDirty as any).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCliLanguage(null);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('status freshness from per-file drift (#3077)', () => {
|
||||
it('is up-to-date when every covered file matches, despite a dirty working tree', async () => {
|
||||
// The reported case: one modified file outside the index's coverage. The
|
||||
// old repo-wide check called this stale and `analyze` could not clear it.
|
||||
(isWorkingTreeDirty as any).mockReturnValue(true);
|
||||
(detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 210 });
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'up-to-date',
|
||||
contentDrift: { status: 'current', coveredFiles: 210 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports covered-file drift as stale and names the files', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed: ['src/app.ts'],
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).not.toContain('up-to-date');
|
||||
expect(out).toContain('1 changed, 0 added, 0 deleted');
|
||||
expect(out).toContain('changed: src/app.ts');
|
||||
});
|
||||
|
||||
it('escapes control characters in drifted path names', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed: ['src/\u001b[31mevil.ts'],
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).toContain(JSON.stringify('src/\u001b[31mevil.ts'));
|
||||
expect(out).not.toContain('\u001b[31m');
|
||||
});
|
||||
|
||||
it('localizes overflow category labels in zh-CN', async () => {
|
||||
setCliLanguage('zh-CN');
|
||||
const changed = Array.from({ length: 12 }, (_, i) => `src/file-${i}.ts`);
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed,
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).toContain('已修改: src/file-0.ts');
|
||||
expect(out).toContain('另有 2 个 已修改');
|
||||
expect(out).not.toMatch(/\bchanged\b/);
|
||||
});
|
||||
|
||||
it('names a failed coverage scan in human output instead of falling back', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'scan-failed',
|
||||
});
|
||||
|
||||
await statusCommand();
|
||||
|
||||
const out = output();
|
||||
expect(out).toContain('coverage scan failed');
|
||||
expect(out).toContain('stale');
|
||||
expect(out).not.toContain('fell back to the working-tree check');
|
||||
});
|
||||
|
||||
it('exposes drift counts and a capped sample in --json', async () => {
|
||||
const changed = Array.from({ length: 25 }, (_, i) => `src/file-${i}.ts`);
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'drifted',
|
||||
changed,
|
||||
added: [],
|
||||
deleted: [],
|
||||
});
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
const parsed = JSON.parse(output());
|
||||
expect(parsed.status).toBe('stale');
|
||||
expect(parsed.contentDrift.counts).toEqual({ changed: 25, added: 0, deleted: 0 });
|
||||
expect(parsed.contentDrift.changed).toHaveLength(10);
|
||||
expect(parsed.contentDrift.truncated).toEqual({
|
||||
changed: true,
|
||||
added: false,
|
||||
deleted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the working-tree check when coverage cannot be compared', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
(isWorkingTreeDirty as any).mockReturnValue(true);
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'stale',
|
||||
contentDrift: { status: 'unmeasurable', reason: 'no-file-hashes' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is stale when coverage cannot be compared because the scan failed', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'scan-failed',
|
||||
});
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'stale',
|
||||
contentDrift: { status: 'unmeasurable', reason: 'scan-failed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is up-to-date on a clean tree when hashes are missing (legacy metadata)', async () => {
|
||||
(detectIndexContentDrift as any).mockResolvedValue({
|
||||
kind: 'unmeasurable',
|
||||
reason: 'no-file-hashes',
|
||||
});
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'up-to-date',
|
||||
contentDrift: { status: 'unmeasurable', reason: 'no-file-hashes' },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the scan when the index is already stale on metadata alone', async () => {
|
||||
// A moved HEAD is decided without paying for a repository-wide hash pass.
|
||||
(getCurrentCommit as any).mockReturnValue('othersha');
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(detectIndexContentDrift).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
status: 'stale',
|
||||
contentDrift: { status: 'not-checked' },
|
||||
});
|
||||
});
|
||||
|
||||
it('replays persisted indexCoverage into the drift check', async () => {
|
||||
const coverage = { maxFileSizeBytes: 1024 * 1024, dirtyPaths: ['a.js'] };
|
||||
(findRepo as any).mockResolvedValue({
|
||||
...repoWithCoverage,
|
||||
meta: { ...repoWithCoverage.meta, indexCoverage: coverage },
|
||||
});
|
||||
(detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 1 });
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(detectIndexContentDrift).toHaveBeenCalledWith('/repo', { 'a.js': 'sha-a' }, coverage);
|
||||
});
|
||||
});
|
||||
|
|
@ -283,6 +283,25 @@ describe('GITNEXUS_TOOLS', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('repo descriptions explain the cwd default and mutating exception (#3073)', () => {
|
||||
expect(GITNEXUS_TOOLS.find((tool) => tool.name === 'list_repos')?.description).toMatch(
|
||||
/process cwd/i,
|
||||
);
|
||||
expect(GITNEXUS_TOOLS.find((tool) => tool.name === 'list_repos')?.description).toMatch(
|
||||
/unindexed nested Git checkout/i,
|
||||
);
|
||||
for (const tool of GITNEXUS_TOOLS) {
|
||||
if (tool.name === 'list_repos' || GROUP_TOOLS.has(tool.name)) continue;
|
||||
const description = tool.inputSchema.properties.repo.description;
|
||||
if (tool.name === 'rename') {
|
||||
expect(description).toMatch(/mutating tools require an explicit repo/i);
|
||||
} else {
|
||||
expect(description).toMatch(/process cwd/i);
|
||||
expect(description).toMatch(/unindexed nested Git checkout/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('per-repo tools have an optional branch scope param (#2106); group/list tools do not', () => {
|
||||
for (const tool of GITNEXUS_TOOLS) {
|
||||
if (tool.name === 'list_repos' || GROUP_TOOLS.has(tool.name)) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue