mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
fix(mcp): resolve omitted repo from cwd (#3085)
* fix(mcp): resolve omitted repo from cwd * test(mcp): cover cwd repository routing gaps * fix(mcp): harden cwd repository routing * docs(mcp): clarify cwd repository boundary * fix(mcp): preserve resolver compatibility * fix(mcp): align restricted repository routing --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
170eefd4a0
commit
5bad2d8b0b
14 changed files with 639 additions and 78 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.
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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