fix(auto-sync): HTTPS remotes, OpenSSH image, and rc embeddings (#3378)

* fix(docker): install OpenSSH in the CLI runtime image

Auto-sync requires git SSH remotes, but the published image omitted
openssh-client so every clone failed with ssh: not found.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): accept HTTPS remotes and reclone failed checkouts

Allowlisted HTTPS URLs can clone without SSH keys, and a timed-out
clone with no remote.origin is quarantined instead of blocking forever.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): honor .gitnexusrc embeddings and warn on empty vectors

Auto-sync analyze now reads embeddings from the clone's project config,
and query reports when an index has no vectors so keyword fallback is visible.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): warn when CodeEmbedding table is missing (U5)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): wrap long openssh-client test line for prettier

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(query): keep keyword-only indexes off query.warning

Empty or missing CodeEmbedding is the default index. Put the #3372 notice in a once-per-backend log line so FTS-success query results stay warning-free.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): bound rc reads and quarantine only a missing origin

Drop the implementation plan from the branch. Auto-sync reads .gitnexusrc through the bounded control-file reader, and a git config failure no longer relocates a live checkout. The same allowlisted repo can switch between SSH and HTTPS without a refused pull.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(auto-sync): share repo identity and skip a second origin read

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(auto-sync): clean temp fixtures and cover nested embeddings precedence

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(auto-sync): skip the symlink rc fixture on Windows

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(auto-sync): reject symlink rc files on every platform

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(ci): run auto-sync symlink and clone tests on Windows and macOS

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(git-clone): keep Windows CI on file URLs and POSIX permission checks

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(git-clone): keep the SSH-to-HTTPS origin check offline

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergő Magyar 2026-09-25 19:42:26 +01:00 • committed by GitHub
parent 233ca28492
commit 6bb99767ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 473 additions and 44 deletions

View file

@ -55,8 +55,9 @@ RUN npm run postinstall --prefix gitnexus
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git for cloning; procps for watch process identity;
# ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \
# ca-certificates for TLS verification; openssh-client so auto-sync SSH remotes
# can clone (git invokes `ssh`; --no-install-recommends omits it from git).
RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates openssh-client && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack

View file

@ -536,7 +536,7 @@ projects:
```
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- Remote URLs may use SSH SCP or HTTPS and are limited to GitHub, GitLab, or Gitee. The CLI image includes OpenSSH; mount keys yourself. Invalid `watch_config.yml` skips auto-sync immediately. Auto-sync honors `.gitnexusrc` embeddings (HTTP embeddings env still required in the image).
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Set per-project `pdg: true` to keep the full control-flow, control/data-dependence, and taint layers current. Untouched configs that omit `pdg` preserve an existing index's mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG; an explicit `false` opt-out logs a warning before removing existing PDG data. Auto-sync requests atomic incremental publication where supported, so readers keep using the previous graph until a successful update is ready and a failed staged analysis leaves it intact; unsupported paths retain the analyzer's existing in-place behavior.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to half of `sync_interval_minutes`, but may be longer (for example, a `30m` analysis timeout with `5` minute polling) up to Node's timer limit. If a polling tick arrives while analysis is active, it is coalesced into one immediate follow-up run using the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.

View file

@ -367,7 +367,7 @@ projects:
- git@gitee.com:owner/repo.git
```
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` may use SSH SCP form (`git@host:owner/repo.git`) or HTTPS (`https://host/owner/repo.git`) for github.com, gitlab.com, or gitee.com. The published CLI image includes `openssh-client` so SSH remotes can clone; mount keys and `known_hosts` yourself. An invalid `watch_config.yml` skips auto-sync immediately with the validation error. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. Auto-sync analysis honors the cloned repo's `.gitnexusrc` embeddings settings; the CLI image still needs `GITNEXUS_EMBEDDING_URL` or a bind-mounted embedding stack because npm is stripped. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
`pdg` is configured per project. `pdg: true` builds and maintains the full CFG, control-dependence, reaching-definition, and taint layers on both initial and incremental analyses. Auto-sync requests staged atomic incremental publication where the analyzer supports it: the old graph remains available to readers until the replacement succeeds, and analysis errors are recorded while the old graph remains intact. Unsupported paths retain the analyzer's existing in-place behavior. Untouched configs that omit `pdg` preserve the existing index mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG. An explicit `pdg: false` disables PDG and emits a warning before a successful rebuild removes those layers. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch, commit, and requested PDG mode; a new commit, a PDG mode change, or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.

View file

@ -341,6 +341,12 @@ const FILESYSTEM = [
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
// disk — against a 30 s testTimeout.
'test/unit/source-control-bytes.test.ts',
// Auto-sync reads a cloned `.gitnexusrc` through the symlink/hard-link guard,
// and clone recovery uses real `git` plus temp dirs. Ubuntu coverage alone
// would never create the Windows file symlink (`type: 'file'`) or run the
// git-config failure path on windows-latest / macos-latest.
'test/unit/gitnexus-rc-embeddings.test.ts',
'test/unit/git-clone.test.ts',
];
const ALL_CROSS_PLATFORM = [

View file

@ -124,6 +124,8 @@ function defaultSyncConfig(localPath: string): string {
' overwrite_local_changes: false',
' remote_urls:',
' - git@github.com:owner/repo.git',
' # HTTPS remotes on github.com, gitlab.com, or gitee.com are also allowed',
' # - https://github.com/owner/public-repo.git',
'',
].join('\n');
}

View file

@ -211,7 +211,7 @@ export const en = {
'help.command.autoSync.description':
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
'help.autoSync.details':
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: SSH or HTTPS URLs on github.com, gitlab.com, and gitee.com are allowed. Invalid watch_config.yml skips auto-sync immediately.\nRuns once immediately, then repeats on sync_interval_minutes.',
'help.command.watch.description':
'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
'help.watch.details':

View file

@ -199,7 +199,7 @@ export const zhCN = {
'help.command.autoSync.description':
'控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
'help.autoSync.details':
'\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
'\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:允许 github.com、gitlab.com 和 gitee.com 上的 SSH 或 HTTPS 地址。无效的 watch_config.yml 会立即跳过 auto-sync。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
'help.command.watch.description':
'含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`',
'help.watch.details':

View file

@ -285,17 +285,43 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
};
}
export function validateAutoSyncRemoteUrl(remoteUrl: string): void {
export function parseAutoSyncRemoteIdentity(remoteUrl: string): { host: string; repoPath: string } {
const trimmed = remoteUrl.trim();
if (trimmed.includes('?') || trimmed.includes('#')) {
throw new Error('must not include query strings or fragments');
}
const match = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
if (!match) {
throw new Error('must use an SSH URL on github.com, gitlab.com, or gitee.com');
const sshMatch = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
const httpsMatch = /^https:\/\/([^/\s]+)\/([^\s]+)$/.exec(trimmed);
let host: string;
let repoPath: string;
if (sshMatch) {
host = sshMatch[1];
repoPath = sshMatch[2];
} else if (httpsMatch) {
host = httpsMatch[1];
repoPath = httpsMatch[2];
if (host.includes('@') || host.includes(':')) {
throw new Error('must not include userinfo or a port');
}
} else {
throw new Error('must use an SSH or HTTPS URL on github.com, gitlab.com, or gitee.com');
}
const host = match[1].toLowerCase();
const repoPath = match[2];
host = host.toLowerCase();
assertAutoSyncRemotePath(host, repoPath);
return { host, repoPath };
}
/** Canonical `host/owner/repo` key. Strips one trailing `.git`. Throws on an invalid remote. */
export function getAutoSyncRepoIdentity(remoteUrl: string): string {
const { host, repoPath } = parseAutoSyncRemoteIdentity(remoteUrl);
return `${host}/${repoPath.replace(/\.git$/i, '')}`;
}
export function validateAutoSyncRemoteUrl(remoteUrl: string): void {
parseAutoSyncRemoteIdentity(remoteUrl);
}
function assertAutoSyncRemotePath(host: string, repoPath: string): void {
if (!ALLOWED_REMOTE_HOSTS.has(host)) {
throw new Error('host must be one of github.com, gitlab.com, or gitee.com');
}

View file

@ -7,6 +7,7 @@ export {
parseDurationMs,
validateAutoSyncBranchName,
validateAutoSyncRemoteUrl,
parseAutoSyncRemoteIdentity,
type AutoSyncConfig,
type AutoSyncConfigLoadResult,
type AutoSyncProjectConfig,

View file

@ -11,6 +11,8 @@ import {
resolveBranchPlacement,
type RepoMeta,
} from '../../storage/repo-manager.js';
import { embeddingsFromGitnexusRc } from '../gitnexus-rc-embeddings.js';
import { getAutoSyncRepoIdentity } from './config.js';
import { extractRepoNameFromRemoteUrl } from './repo.js';
import { cloneOrPull, runGit } from '../../server/git-clone.js';
import { resolveConfiguredCloneRoot } from './path-security.js';
@ -25,7 +27,6 @@ import {
type ProjectCommitInfoEntry,
} from './state.js';
import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js';
import { validateAutoSyncRemoteUrl } from './config.js';
import {
AutoSyncAnalysisError,
runAutoSyncAnalysis,
@ -243,6 +244,7 @@ export async function runAutoSyncOnce(
})
) {
try {
const rcEmbeddings = await embeddingsFromGitnexusRc(targetDir);
const analysis = await deps.runAnalysis(
targetDir,
{
@ -253,6 +255,7 @@ export async function runAutoSyncOnce(
? { preserveExistingPdg: true }
: { pdg: requestedPdg }),
atomicIncremental: true,
...rcEmbeddings,
},
config.analyzeTimeoutMs,
options.signal,
@ -510,11 +513,7 @@ export async function addRepoToGroup(
return true;
}
export function getAutoSyncRepoIdentity(remoteUrl: string): string {
validateAutoSyncRemoteUrl(remoteUrl);
const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim())!;
return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`;
}
export { getAutoSyncRepoIdentity } from './config.js';
export async function syncGroupByName(groupName: string): Promise<void> {
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);

View file

@ -0,0 +1,63 @@
/**
* Auto-sync reads embeddings settings from a cloned repo's `.gitnexusrc`
* without importing CLI modules (`src/core` must not import `src/cli`).
* Invalid JSON fails the analyze for that repo the same way CLI analyze fails closed.
*/
import type { AnalyzeOptions } from './run-analyze.js';
import { readRepoControlFile } from '../config/repo-control-file.js';
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
export class AutoSyncGitnexusRcError extends Error {
constructor(message: string) {
super(message);
this.name = 'AutoSyncGitnexusRcError';
}
}
export async function embeddingsFromGitnexusRc(
repoRoot: string,
): Promise<Pick<AnalyzeOptions, 'embeddings' | 'embeddingsNodeLimit'>> {
let raw: string | null;
try {
raw = await readRepoControlFile(repoRoot, GITNEXUS_RC_FILENAME);
} catch (err) {
throw new AutoSyncGitnexusRcError(
`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`,
);
}
if (raw === null) return {};
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} is not valid JSON`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
}
const obj = parsed as Record<string, unknown>;
let embeddingsVal: unknown = obj.embeddings;
if (obj.analyze && typeof obj.analyze === 'object' && !Array.isArray(obj.analyze)) {
const nested = obj.analyze as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(nested, 'embeddings')) {
embeddingsVal = nested.embeddings;
}
}
if (embeddingsVal === undefined) return {};
if (typeof embeddingsVal === 'boolean') {
return { embeddings: embeddingsVal };
}
if (typeof embeddingsVal === 'number') {
if (!Number.isInteger(embeddingsVal) || embeddingsVal < 0) {
throw new AutoSyncGitnexusRcError(
`${GITNEXUS_RC_FILENAME} embeddings must be true/false or a non-negative integer`,
);
}
return { embeddings: true, embeddingsNodeLimit: embeddingsVal };
}
throw new AutoSyncGitnexusRcError(
`${GITNEXUS_RC_FILENAME} embeddings must be a boolean or a non-negative integer`,
);
}

View file

@ -1634,6 +1634,7 @@ export class LocalBackend {
* degradation is visible once instead of silent.
*/
private warnedMissingEmbeddingStack = false;
private warnedNoEmbeddingVectors = false;
/**
* Width the semantic lane last produced a QUERY vector at for an index, keyed
@ -3880,6 +3881,18 @@ export class LocalBackend {
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
}
/**
* Keyword-only indexes are the default. Do not put this on `query.warning`
* (that field is reserved for FTS/stack degradation). Log once per backend.
*/
private logKeywordOnlyEmbeddings(): void {
if (this.warnedNoEmbeddingVectors) return;
this.warnedNoEmbeddingVectors = true;
logger.warn(
'GitNexus [query:vector]: This index has no embedding vectors — results are keyword-only. Enable embeddings in `.gitnexusrc` (auto-sync honors that file) or run `gitnexus analyze --embeddings`.',
);
}
/**
* Semantic vector search helper
*/
@ -3902,10 +3915,8 @@ export class LocalBackend {
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN COUNT(*) AS cnt LIMIT 1`,
);
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) {
// No vectors to search: nothing is embedded below, so drop any width a
// previous call recorded rather than let query() warn about a lane that
// did not run this time (#2798).
this.lastQueryEmbeddingDims.delete(repo.lbugPath);
this.logKeywordOnlyEmbeddings();
return [];
}
@ -4067,6 +4078,8 @@ export class LocalBackend {
isLocalEmbeddingSidecarAbortMessage(message);
if (isDegradedVectorError) {
if (degraded) degraded.reason = message;
} else if (isBenignMissingTableError(err)) {
this.logKeywordOnlyEmbeddings();
}
if (!this.warnedMissingEmbeddingStack && isDegradedVectorError) {
this.warnedMissingEmbeddingStack = true;

View file

@ -18,7 +18,7 @@ import {
assertDirectoryOwnerAndPermissions,
quarantineAutoSyncPartial,
} from '../core/auto-sync/path-security.js';
import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
import { getAutoSyncRepoIdentity, validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
export { validateGitUrl };
@ -318,21 +318,31 @@ export function normalizeGitUrlForCompare(url: string): string {
}
}
/** Same allowlisted repo across SSH and HTTPS, ignoring a trailing `.git`. */
function sameAllowlistedAutoSyncRepo(left: string, right: string): boolean {
try {
return getAutoSyncRepoIdentity(left) === getAutoSyncRepoIdentity(right);
} catch {
return false;
}
}
/**
* Read `remote.origin.url` from an existing clone using `git config --get`.
*
* Returns `null` if the config key is absent, the spawn fails, or the
* directory isn't a git repository. The caller decides what a missing
* remote means for its threat model — for cloneOrPull, a missing remote
* on an existing clone is treated as a refuse-to-pull condition.
* Returns `null` only when the key is absent (exit 1) or empty. Timeouts and
* every other git failure throw, so a lock or spawn error is not treated as
* a missing origin.
*/
export async function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Promise<string | null> {
try {
const stdout = await runGit(['config', '--get', 'remote.origin.url'], cwd, { timeoutMs });
return stdout.trim() || null;
} catch (error) {
if ((error as Error).message.includes('timed out')) throw error;
return null;
const message = (error as Error).message ?? '';
if (message.includes('timed out')) throw error;
if (message.includes('failed (exit code 1)')) return null;
throw error;
}
}
@ -354,8 +364,9 @@ export async function assertRemoteMatchesRequestedUrl(
targetDir: string,
requestedUrl: string,
timeoutMs?: number,
knownOriginUrl?: string,
): Promise<void> {
const remoteUrl = await getRemoteOriginUrl(targetDir, timeoutMs);
const remoteUrl = knownOriginUrl ?? (await getRemoteOriginUrl(targetDir, timeoutMs));
if (remoteUrl === null) {
throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`);
}
@ -552,11 +563,26 @@ export async function cloneOrPull(
await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
await assertPreRealpathContainment(cloneRoot, safeTarget);
const exists = await fs.access(path.join(safeTarget, '.git')).then(
let exists = await fs.access(path.join(safeTarget, '.git')).then(
() => true,
() => false,
);
let originUrl: string | null = null;
if (exists && options?.allowAutoSyncSsh) {
originUrl = await getRemoteOriginUrl(safeTarget, options?.timeoutMs);
if (!originUrl) {
if (options.quarantineRoot) {
await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot);
exists = false;
} else {
throw new Error(
`Existing clone at ${safeTarget} has no remote.origin — remove ${safeTarget} and retry`,
);
}
}
}
const targetExists = await fs.access(safeTarget).then(
() => true,
() => false,
@ -567,10 +593,26 @@ export async function cloneOrPull(
await assertNoSymlinkPath(cloneRoot, path.join(safeTarget, '.git'), true);
}
await assertPostRealpathContainment(cloneRoot, safeTarget);
let originForCompare = originUrl;
if (
originUrl &&
normalizeGitUrlForCompare(originUrl) !== normalizeGitUrlForCompare(url) &&
sameAllowlistedAutoSyncRepo(originUrl, url)
) {
await runGit(['remote', 'set-url', 'origin', url], safeTarget, {
timeoutMs: options?.timeoutMs,
});
originForCompare = url;
}
// Confirm the existing clone is actually the same repository the caller
// requested. Without this check, a pull would silently succeed against
// whatever remote the dir was originally cloned from.
await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs);
await assertRemoteMatchesRequestedUrl(
safeTarget,
url,
options?.timeoutMs,
originForCompare ?? undefined,
);
onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
const runGitImpl = options?.runGitForTest ?? runGit;
const gitOpts = {

View file

@ -223,6 +223,43 @@ describe('auto-sync runner', () => {
]);
});
it('passes embeddings from .gitnexusrc into runAnalysis', async () => {
const targetDir = '/tmp/repos/gitee.com/qts_server/qts_account';
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(path.join(targetDir, '.gitnexusrc'), '{"embeddings": true}');
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => targetDir),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
try {
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(deps.runAnalysis).toHaveBeenCalledWith(
targetDir,
expect.objectContaining({ embeddings: true }),
1_800_000,
undefined,
undefined,
1,
);
} finally {
await fs.rm(path.join(targetDir, '.gitnexusrc'), { force: true });
}
});
it('enables PDG atomically at an unchanged commit when project configuration opts in', async () => {
const pdgConfig: AutoSyncConfig = {
...config,
@ -573,6 +610,9 @@ describe('auto-sync runner', () => {
expect(getAutoSyncRepoIdentity('git@GitHub.com:team/service.GIT')).toBe(
'github.com/team/service',
);
expect(getAutoSyncRepoIdentity('https://github.com/team/service.git')).toBe(
'github.com/team/service',
);
});
it('skips analysis when commit id has not changed', async () => {
@ -1238,28 +1278,39 @@ describe('auto-sync runner', () => {
expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1);
});
it('rejects non auto-sync SSH URLs at runner boundary', async () => {
const invalidConfig: AutoSyncConfig = {
it('clones allowlisted HTTPS remotes at the runner boundary', async () => {
const httpsConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], remoteUrls: ['https://github.com/owner/repo.git'] }],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(),
cloneOrPull: vi.fn(async () => '/tmp/repos/github.com/owner/repo'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'abc'),
runAnalysis: vi.fn(async () => ({ stats: {} })),
registerRepo: vi.fn(async () => 'repo'),
});
const result = await runAutoSyncOnce(invalidConfig, {
const error = vi.fn();
const result = await runAutoSyncOnce(httpsConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
logger: { info: vi.fn(), warn: vi.fn(), error },
});
expect(result.failed).toBe(1);
expect(deps.cloneOrPull).not.toHaveBeenCalled();
expect(error.mock.calls, JSON.stringify(error.mock.calls)).toEqual([]);
expect(result.failed).toBe(0);
expect(deps.cloneOrPull).toHaveBeenCalledWith(
'https://github.com/owner/repo.git',
'/tmp/repos/github.com/owner/repo',
undefined,
expect.any(Object),
);
});
it('resets consecutive analyze failures when the code commit changes, then records this failure', async () => {

View file

@ -549,18 +549,23 @@ describe('auto-sync', () => {
expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/..')).toThrow('traversal');
});
it('allows only github, gitlab, and gitee SSH SCP remote URLs', () => {
it('allows github, gitlab, and gitee SSH SCP and HTTPS remote URLs', () => {
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo')).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('git@github.com:im-fan/multica.git')).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('git@gitlab.com:group/subgroup/repo.git')).not.toThrow();
expect(() =>
validateAutoSyncRemoteUrl('git@gitee.com:qts-ops/qts-code-engineering.git'),
).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).toThrow(
'must use',
expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('https://gitlab.com/group/repo.git')).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('http://github.com/owner/repo.git')).toThrow(
'must use an SSH or HTTPS',
);
expect(() => validateAutoSyncRemoteUrl('https://user:token@github.com/owner/repo.git')).toThrow(
'userinfo',
);
expect(() => validateAutoSyncRemoteUrl('ssh://git@github.com/owner/repo.git')).toThrow(
'must use',
'must use an SSH or HTTPS',
);
expect(() => validateAutoSyncRemoteUrl('user@github.com:owner/repo.git')).toThrow('must use');
expect(() => validateAutoSyncRemoteUrl('git@example.com:owner/repo.git')).toThrow(

View file

@ -442,4 +442,20 @@ describe('Dockerfile.cli runtime-stage asset parity (#2130)', () => {
// covered by the whole-`hooks` COPY — coverage-check, not existence-check.
expect(isCovered('hooks/antigravity/hook-lock.cjs', copied)).toBe(true);
});
it('installs openssh-client in the runtime apt-get line (#3372)', () => {
const lines = dockerfile.split('\n');
const runtimeStart = lines.findIndex((l) => /^FROM\s.*\bAS\s+runtime\b/i.test(l));
const runtimeBody: string[] = [];
for (const line of lines.slice(runtimeStart + 1)) {
if (/^FROM\b/.test(line)) break;
runtimeBody.push(line);
}
const install = runtimeBody.find((l) => /apt-get install/.test(l));
expect(install, 'runtime stage must apt-get install packages').toBeDefined();
for (const pkg of ['curl', 'git', 'procps', 'ca-certificates', 'openssh-client']) {
expect(install).toContain(pkg);
}
expect(install).not.toMatch(/\bapt-get install\b[\s\S]*\bpython3\b/);
});
});

View file

@ -29,6 +29,7 @@ import {
isAzureDevOpsUrl,
warnIfInsecureAzureConfig,
runGitForTest,
getRemoteOriginUrl as serverGetRemoteOriginUrl,
} from '../../src/server/git-clone.js';
import path from 'node:path';
import os from 'node:os';
@ -1229,12 +1230,16 @@ describe('git-clone', () => {
await runGit(['checkout', '-b', 'main'], source);
await fs.writeFile(path.join(source, 'branch.txt'), 'main\n');
await runGit(['commit', '-am', 'main'], source);
await runGit(['remote', 'add', 'origin', `file://${remote}`], source);
const remotePosix = remote.replace(/\\/g, '/');
const remoteFileUrl = remotePosix.startsWith('/')
? `file://${remotePosix}`
: `file:///${remotePosix}`;
await runGit(['remote', 'add', 'origin', remoteFileUrl], source);
await runGit(['push', 'origin', 'master', 'main'], source);
await fs.writeFile(
gitConfig,
`[protocol "file"]\n\tallow = always\n[url "file://${remote}"]\n\tinsteadOf = ${remoteUrl}\n`,
`[protocol "file"]\n\tallow = always\n[url "${remoteFileUrl}"]\n\tinsteadOf = ${remoteUrl}\n`,
);
process.env.GIT_CONFIG_GLOBAL = gitConfig;
process.env.GIT_CONFIG_NOSYSTEM = '1';
@ -1328,6 +1333,103 @@ describe('git-clone', () => {
}
});
it('quarantines auto-sync clones that have .git but no remote.origin and reclones', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const quarantineRoot = path.join(root, 'quarantine');
const target = path.join(root, 'repo');
try {
await fs.mkdir(target);
await new Promise<void>((resolve, reject) => {
const proc = spawn('git', ['init', '--quiet'], { cwd: target, stdio: 'ignore' });
proc.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)),
);
proc.on('error', reject);
});
const runGitForTest = vi.fn(async (args: string[]) => {
if (args[0] === 'clone') {
await fs.mkdir(target, { recursive: true });
return '';
}
return '';
});
await expect(
cloneOrPull('git@github.com:owner/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
quarantineRoot,
runGitForTest,
}),
).resolves.toBe(target);
const entries = await fs.readdir(quarantineRoot);
expect(entries.some((entry) => entry.includes('repo'))).toBe(true);
expect(runGitForTest.mock.calls.some((call) => call[0][0] === 'clone')).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('does not quarantine when git config fails for a reason other than a missing origin', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const quarantineRoot = path.join(root, 'quarantine');
const target = path.join(root, 'repo');
try {
await fs.mkdir(target);
await fs.writeFile(path.join(target, '.git'), 'not-a-git-dir');
await expect(
cloneOrPull('git@github.com:owner/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
quarantineRoot,
}),
).rejects.toThrow(/exit code (?!1\b)/);
await expect(fs.readFile(path.join(target, '.git'), 'utf-8')).resolves.toBe(
'not-a-git-dir',
);
await expect(fs.access(quarantineRoot)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('points an SSH origin at the requested HTTPS URL for the same repo', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const quarantineRoot = path.join(root, 'quarantine');
const target = path.join(root, 'repo');
try {
await fs.mkdir(target);
await new Promise<void>((resolve, reject) => {
const proc = spawn('git', ['init', '--quiet'], { cwd: target, stdio: 'ignore' });
proc.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)),
);
proc.on('error', reject);
});
await runGitForTest(['remote', 'add', 'origin', 'git@github.com:owner/repo.git'], target);
const runGitForPull = vi.fn(async (args: string[]) => {
if (args[0] === 'pull' || args[0] === 'fetch') throw new Error('offline');
return '';
});
await expect(
cloneOrPull('https://github.com/owner/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
quarantineRoot,
runGitForTest: runGitForPull,
}),
).rejects.toThrow('offline');
await expect(serverGetRemoteOriginUrl(target)).resolves.toBe(
'https://github.com/owner/repo.git',
);
await expect(fs.access(quarantineRoot)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('does not quarantine an existing non-git directory on clone failure', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const quarantineRoot = path.join(root, 'quarantine');
@ -1354,6 +1456,7 @@ describe('git-clone', () => {
});
it('rejects controlled clone roots with unsafe permissions inside cloneOrPull', async () => {
if (process.platform === 'win32') return;
const root = await mkControlledRoot('gitnexus-controlled-root-');
try {
await fs.chmod(root, 0o777);

View file

@ -0,0 +1,57 @@
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
embeddingsFromGitnexusRc,
AutoSyncGitnexusRcError,
} from '../../src/core/gitnexus-rc-embeddings.js';
describe('embeddingsFromGitnexusRc', () => {
const dirs: string[] = [];
const tempDir = (): string => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rc-'));
dirs.push(dir);
return dir;
};
afterEach(() => {
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
it('returns empty when no rc file exists', async () => {
await expect(embeddingsFromGitnexusRc(tempDir())).resolves.toEqual({});
});
it('reads embeddings true from a committed rc', async () => {
const dir = tempDir();
fs.writeFileSync(path.join(dir, '.gitnexusrc'), '{"embeddings": true}');
await expect(embeddingsFromGitnexusRc(dir)).resolves.toEqual({ embeddings: true });
});
it('prefers nested analyze.embeddings over a conflicting top-level value', async () => {
const dir = tempDir();
fs.writeFileSync(
path.join(dir, '.gitnexusrc'),
'{"embeddings": false, "analyze": {"embeddings": 100}}',
);
await expect(embeddingsFromGitnexusRc(dir)).resolves.toEqual({
embeddings: true,
embeddingsNodeLimit: 100,
});
});
it('fails closed on invalid JSON', async () => {
const dir = tempDir();
fs.writeFileSync(path.join(dir, '.gitnexusrc'), '{');
await expect(embeddingsFromGitnexusRc(dir)).rejects.toThrow(AutoSyncGitnexusRcError);
});
it('refuses a symlink .gitnexusrc', async () => {
const dir = tempDir();
const outside = path.join(dir, 'outside.json');
fs.writeFileSync(outside, '{"embeddings": true}');
// 'file' is required for a file symlink on Windows and ignored on other platforms.
fs.symlinkSync(outside, path.join(dir, '.gitnexusrc'), 'file');
await expect(embeddingsFromGitnexusRc(dir)).rejects.toThrow(/symbolic link/);
});
});

View file

@ -97,4 +97,48 @@ describe('LocalBackend.semanticSearch — missing-stack warning (#2372)', () =>
cap.restore();
}
});
it('logs once when the index has no embedding rows (#3372)', async () => {
executeQueryMock.mockResolvedValue([{ cnt: 0 }]);
const backend = new LocalBackend();
const cap = _captureLogger();
const degraded = { reason: undefined as string | undefined };
try {
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
expect(degraded.reason).toBeUndefined();
expect(embedQueryMock).not.toHaveBeenCalled();
expect(
cap
.records()
.filter((r) => typeof r.msg === 'string' && r.msg.includes('no embedding vectors'))
.length,
).toBe(1);
} finally {
cap.restore();
}
});
it('logs once when the embedding table is missing (#3372)', async () => {
executeQueryMock.mockRejectedValue(
new Error('Binder exception: Table CodeEmbedding does not exist.'),
);
const backend = new LocalBackend();
const cap = _captureLogger();
const degraded = { reason: undefined as string | undefined };
try {
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
expect(await callSemanticSearch(backend, degraded)).toEqual([]);
expect(degraded.reason).toBeUndefined();
expect(embedQueryMock).not.toHaveBeenCalled();
expect(
cap
.records()
.filter((r) => typeof r.msg === 'string' && r.msg.includes('no embedding vectors'))
.length,
).toBe(1);
} finally {
cap.restore();
}
});
});