gitnexus 1.6.13-rc.36 → 1.6.13-rc.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -367,7 +367,7 @@ projects:
367
367
  - git@gitee.com:owner/repo.git
368
368
  ```
369
369
 
370
- `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.
370
+ `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.
371
371
 
372
372
  `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`.
373
373
 
@@ -106,6 +106,8 @@ function defaultSyncConfig(localPath) {
106
106
  ' overwrite_local_changes: false',
107
107
  ' remote_urls:',
108
108
  ' - git@github.com:owner/repo.git',
109
+ ' # HTTPS remotes on github.com, gitlab.com, or gitee.com are also allowed',
110
+ ' # - https://github.com/owner/public-repo.git',
109
111
  '',
110
112
  ].join('\n');
111
113
  }
@@ -170,7 +170,7 @@ export declare const en: {
170
170
  readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
171
171
  readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
172
172
  readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
173
- readonly '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.';
173
+ readonly '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: 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.';
174
174
  readonly 'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes';
175
175
  readonly 'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
176
176
  readonly 'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
@@ -172,7 +172,7 @@ export const en = {
172
172
  'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid',
173
173
  'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
174
174
  'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
175
- '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.',
175
+ '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: 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.',
176
176
  'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
177
177
  'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
178
178
  'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
@@ -171,7 +171,7 @@ export declare const cliResources: {
171
171
  readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
172
172
  readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
173
173
  readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
174
- readonly '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.';
174
+ readonly '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: 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.';
175
175
  readonly 'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes';
176
176
  readonly 'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
177
177
  readonly 'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n';
@@ -170,7 +170,7 @@ export const zhCN = {
170
170
  'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex、Factory Droid 配置 MCP',
171
171
  'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
172
172
  'help.command.autoSync.description': '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
173
- '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 重复。',
173
+ '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 或 HTTPS 地址。无效的 watch_config.yml 会立即跳过 auto-sync。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
174
174
  'help.command.watch.description': '含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`',
175
175
  'help.watch.details': '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
176
176
  'error.watch.ambiguous': '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
@@ -30,6 +30,12 @@ export declare function getAutoSyncConfigPath(gitnexusDir?: string): string;
30
30
  export declare function parseBranchCandidates(branchValue: unknown): string[];
31
31
  export declare function loadAutoSyncConfig(configPath?: string): Promise<AutoSyncConfigLoadResult>;
32
32
  export declare function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig;
33
+ export declare function parseAutoSyncRemoteIdentity(remoteUrl: string): {
34
+ host: string;
35
+ repoPath: string;
36
+ };
37
+ /** Canonical `host/owner/repo` key. Strips one trailing `.git`. Throws on an invalid remote. */
38
+ export declare function getAutoSyncRepoIdentity(remoteUrl: string): string;
33
39
  export declare function validateAutoSyncRemoteUrl(remoteUrl: string): void;
34
40
  export declare function validateAutoSyncBranchName(branch: string): void;
35
41
  export declare function parseDurationMs(value: unknown): number;
@@ -237,17 +237,42 @@ export function parseAutoSyncConfig(content, configPath) {
237
237
  projects,
238
238
  };
239
239
  }
240
- export function validateAutoSyncRemoteUrl(remoteUrl) {
240
+ export function parseAutoSyncRemoteIdentity(remoteUrl) {
241
241
  const trimmed = remoteUrl.trim();
242
242
  if (trimmed.includes('?') || trimmed.includes('#')) {
243
243
  throw new Error('must not include query strings or fragments');
244
244
  }
245
- const match = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
246
- if (!match) {
247
- throw new Error('must use an SSH URL on github.com, gitlab.com, or gitee.com');
245
+ const sshMatch = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
246
+ const httpsMatch = /^https:\/\/([^/\s]+)\/([^\s]+)$/.exec(trimmed);
247
+ let host;
248
+ let repoPath;
249
+ if (sshMatch) {
250
+ host = sshMatch[1];
251
+ repoPath = sshMatch[2];
252
+ }
253
+ else if (httpsMatch) {
254
+ host = httpsMatch[1];
255
+ repoPath = httpsMatch[2];
256
+ if (host.includes('@') || host.includes(':')) {
257
+ throw new Error('must not include userinfo or a port');
258
+ }
259
+ }
260
+ else {
261
+ throw new Error('must use an SSH or HTTPS URL on github.com, gitlab.com, or gitee.com');
248
262
  }
249
- const host = match[1].toLowerCase();
250
- const repoPath = match[2];
263
+ host = host.toLowerCase();
264
+ assertAutoSyncRemotePath(host, repoPath);
265
+ return { host, repoPath };
266
+ }
267
+ /** Canonical `host/owner/repo` key. Strips one trailing `.git`. Throws on an invalid remote. */
268
+ export function getAutoSyncRepoIdentity(remoteUrl) {
269
+ const { host, repoPath } = parseAutoSyncRemoteIdentity(remoteUrl);
270
+ return `${host}/${repoPath.replace(/\.git$/i, '')}`;
271
+ }
272
+ export function validateAutoSyncRemoteUrl(remoteUrl) {
273
+ parseAutoSyncRemoteIdentity(remoteUrl);
274
+ }
275
+ function assertAutoSyncRemotePath(host, repoPath) {
251
276
  if (!ALLOWED_REMOTE_HOSTS.has(host)) {
252
277
  throw new Error('host must be one of github.com, gitlab.com, or gitee.com');
253
278
  }
@@ -1,4 +1,4 @@
1
- export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, type AutoSyncConfig, type AutoSyncConfigLoadResult, type AutoSyncProjectConfig, } from './config.js';
1
+ export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, parseAutoSyncRemoteIdentity, type AutoSyncConfig, type AutoSyncConfigLoadResult, type AutoSyncProjectConfig, } from './config.js';
2
2
  export { buildStateKey, getAutoSyncMutexPath, getAutoSyncWatchDir, getAutoSyncStatePath, getProjectCommitInfoPath, loadAutoSyncState, resetAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, type AutoSyncAnalyzeStatus, type AutoSyncCommitState, type AutoSyncCommitStateEntry, type ProjectCommitInfoEntry, } from './state.js';
3
3
  export { extractRepoNameFromRemoteUrl } from './repo.js';
4
4
  export { normalizeConfiguredCloneRoot, quarantineAutoSyncPartial, resolveConfiguredCloneRoot, type AutoSyncCloneRoot, } from './path-security.js';
@@ -1,4 +1,4 @@
1
- export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, } from './config.js';
1
+ export { AUTO_SYNC_CONFIG_FILE, getAutoSyncConfigPath, loadAutoSyncConfig, parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, validateAutoSyncBranchName, validateAutoSyncRemoteUrl, parseAutoSyncRemoteIdentity, } from './config.js';
2
2
  export { buildStateKey, getAutoSyncMutexPath, getAutoSyncWatchDir, getAutoSyncStatePath, getProjectCommitInfoPath, loadAutoSyncState, resetAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, } from './state.js';
3
3
  export { extractRepoNameFromRemoteUrl } from './repo.js';
4
4
  export { normalizeConfiguredCloneRoot, quarantineAutoSyncPartial, resolveConfiguredCloneRoot, } from './path-security.js';
@@ -42,6 +42,6 @@ export declare function runAutoSyncOnce(config: AutoSyncConfig, options?: {
42
42
  }): Promise<AutoSyncRunResult>;
43
43
  export declare function getConfiguredRepoPath(project: Pick<AutoSyncProjectConfig, 'localPath'>, repoName: string, remoteUrl?: string): string;
44
44
  export declare function addRepoToGroup(project: Pick<AutoSyncProjectConfig, 'groupName'>, groupPath: string, registryName?: string): Promise<boolean>;
45
- export declare function getAutoSyncRepoIdentity(remoteUrl: string): string;
45
+ export { getAutoSyncRepoIdentity } from './config.js';
46
46
  export declare function syncGroupByName(groupName: string): Promise<void>;
47
47
  export declare function resolveActualConcurrency(configured: number, availableMemoryGB: number): number;
@@ -5,11 +5,12 @@ import { loadGroupConfig } from '../group/config-parser.js';
5
5
  import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
6
6
  import { syncGroup } from '../group/sync.js';
7
7
  import { getStoragePaths, loadMeta, registerRepo, resolveBranchPlacement, } from '../../storage/repo-manager.js';
8
+ import { embeddingsFromGitnexusRc } from '../gitnexus-rc-embeddings.js';
9
+ import { getAutoSyncRepoIdentity } from './config.js';
8
10
  import { extractRepoNameFromRemoteUrl } from './repo.js';
9
11
  import { cloneOrPull, runGit } from '../../server/git-clone.js';
10
12
  import { resolveConfiguredCloneRoot } from './path-security.js';
11
13
  import { buildStateKey, loadAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, } from './state.js';
12
- import { validateAutoSyncRemoteUrl } from './config.js';
13
14
  import { AutoSyncAnalysisError, runAutoSyncAnalysis, } from './analysis-worker-launch.js';
14
15
  const _require = createRequire(import.meta.url);
15
16
  const yaml = _require('js-yaml');
@@ -149,6 +150,7 @@ export async function runAutoSyncOnce(config, options = {}) {
149
150
  previousStatus: previous?.lastAnalyzeStatus,
150
151
  })) {
151
152
  try {
153
+ const rcEmbeddings = await embeddingsFromGitnexusRc(targetDir);
152
154
  const analysis = await deps.runAnalysis(targetDir, {
153
155
  branch: currentBranch,
154
156
  skipAgentsMd: true,
@@ -157,6 +159,7 @@ export async function runAutoSyncOnce(config, options = {}) {
157
159
  ? { preserveExistingPdg: true }
158
160
  : { pdg: requestedPdg }),
159
161
  atomicIncremental: true,
162
+ ...rcEmbeddings,
160
163
  }, config.analyzeTimeoutMs, options.signal, options.onAnalysisCancellationRequested, analysisParallelism);
161
164
  throwIfAborted(options.signal);
162
165
  stats = analysis.stats;
@@ -386,11 +389,7 @@ export async function addRepoToGroup(project, groupPath, registryName = groupPat
386
389
  await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config);
387
390
  return true;
388
391
  }
389
- export function getAutoSyncRepoIdentity(remoteUrl) {
390
- validateAutoSyncRemoteUrl(remoteUrl);
391
- const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim());
392
- return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`;
393
- }
392
+ export { getAutoSyncRepoIdentity } from './config.js';
394
393
  export async function syncGroupByName(groupName) {
395
394
  const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
396
395
  const config = await loadGroupConfig(groupDir);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Auto-sync reads embeddings settings from a cloned repo's `.gitnexusrc`
3
+ * without importing CLI modules (`src/core` must not import `src/cli`).
4
+ * Invalid JSON fails the analyze for that repo the same way CLI analyze fails closed.
5
+ */
6
+ import type { AnalyzeOptions } from './run-analyze.js';
7
+ export declare const GITNEXUS_RC_FILENAME = ".gitnexusrc";
8
+ export declare class AutoSyncGitnexusRcError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export declare function embeddingsFromGitnexusRc(repoRoot: string): Promise<Pick<AnalyzeOptions, 'embeddings' | 'embeddingsNodeLimit'>>;
@@ -0,0 +1,51 @@
1
+ import { readRepoControlFile } from '../config/repo-control-file.js';
2
+ export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
3
+ export class AutoSyncGitnexusRcError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'AutoSyncGitnexusRcError';
7
+ }
8
+ }
9
+ export async function embeddingsFromGitnexusRc(repoRoot) {
10
+ let raw;
11
+ try {
12
+ raw = await readRepoControlFile(repoRoot, GITNEXUS_RC_FILENAME);
13
+ }
14
+ catch (err) {
15
+ throw new AutoSyncGitnexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${err.message}`);
16
+ }
17
+ if (raw === null)
18
+ return {};
19
+ if (raw.charCodeAt(0) === 0xfeff)
20
+ raw = raw.slice(1);
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(raw);
24
+ }
25
+ catch {
26
+ throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} is not valid JSON`);
27
+ }
28
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
29
+ throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
30
+ }
31
+ const obj = parsed;
32
+ let embeddingsVal = obj.embeddings;
33
+ if (obj.analyze && typeof obj.analyze === 'object' && !Array.isArray(obj.analyze)) {
34
+ const nested = obj.analyze;
35
+ if (Object.prototype.hasOwnProperty.call(nested, 'embeddings')) {
36
+ embeddingsVal = nested.embeddings;
37
+ }
38
+ }
39
+ if (embeddingsVal === undefined)
40
+ return {};
41
+ if (typeof embeddingsVal === 'boolean') {
42
+ return { embeddings: embeddingsVal };
43
+ }
44
+ if (typeof embeddingsVal === 'number') {
45
+ if (!Number.isInteger(embeddingsVal) || embeddingsVal < 0) {
46
+ throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} embeddings must be true/false or a non-negative integer`);
47
+ }
48
+ return { embeddings: true, embeddingsNodeLimit: embeddingsVal };
49
+ }
50
+ throw new AutoSyncGitnexusRcError(`${GITNEXUS_RC_FILENAME} embeddings must be a boolean or a non-negative integer`);
51
+ }
@@ -317,6 +317,7 @@ export declare class LocalBackend {
317
317
  * degradation is visible once instead of silent.
318
318
  */
319
319
  private warnedMissingEmbeddingStack;
320
+ private warnedNoEmbeddingVectors;
320
321
  /**
321
322
  * Width the semantic lane last produced a QUERY vector at for an index, keyed
322
323
  * by `lbugPath` (like `lastObservedPoolState`, and for the same reason: branch
@@ -622,6 +623,11 @@ export declare class LocalBackend {
622
623
  * BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
623
624
  */
624
625
  private bm25Search;
626
+ /**
627
+ * Keyword-only indexes are the default. Do not put this on `query.warning`
628
+ * (that field is reserved for FTS/stack degradation). Log once per backend.
629
+ */
630
+ private logKeywordOnlyEmbeddings;
625
631
  /**
626
632
  * Semantic vector search helper
627
633
  */
@@ -1060,6 +1060,7 @@ export class LocalBackend {
1060
1060
  * degradation is visible once instead of silent.
1061
1061
  */
1062
1062
  warnedMissingEmbeddingStack = false;
1063
+ warnedNoEmbeddingVectors = false;
1063
1064
  /**
1064
1065
  * Width the semantic lane last produced a QUERY vector at for an index, keyed
1065
1066
  * by `lbugPath` (like `lastObservedPoolState`, and for the same reason: branch
@@ -3043,6 +3044,16 @@ export class LocalBackend {
3043
3044
  }
3044
3045
  return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
3045
3046
  }
3047
+ /**
3048
+ * Keyword-only indexes are the default. Do not put this on `query.warning`
3049
+ * (that field is reserved for FTS/stack degradation). Log once per backend.
3050
+ */
3051
+ logKeywordOnlyEmbeddings() {
3052
+ if (this.warnedNoEmbeddingVectors)
3053
+ return;
3054
+ this.warnedNoEmbeddingVectors = true;
3055
+ 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`.');
3056
+ }
3046
3057
  /**
3047
3058
  * Semantic vector search helper
3048
3059
  */
@@ -3057,10 +3068,8 @@ export class LocalBackend {
3057
3068
  // the count is read, to decide whether to load the embedding model at all.
3058
3069
  const tableCheck = await executeQuery(repo.lbugPath, `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN COUNT(*) AS cnt LIMIT 1`);
3059
3070
  if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) {
3060
- // No vectors to search: nothing is embedded below, so drop any width a
3061
- // previous call recorded rather than let query() warn about a lane that
3062
- // did not run this time (#2798).
3063
3071
  this.lastQueryEmbeddingDims.delete(repo.lbugPath);
3072
+ this.logKeywordOnlyEmbeddings();
3064
3073
  return [];
3065
3074
  }
3066
3075
  const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
@@ -3204,6 +3213,9 @@ export class LocalBackend {
3204
3213
  if (degraded)
3205
3214
  degraded.reason = message;
3206
3215
  }
3216
+ else if (isBenignMissingTableError(err)) {
3217
+ this.logKeywordOnlyEmbeddings();
3218
+ }
3207
3219
  if (!this.warnedMissingEmbeddingStack && isDegradedVectorError) {
3208
3220
  this.warnedMissingEmbeddingStack = true;
3209
3221
  logger.warn(`GitNexus [query:vector]: ${message}`);
@@ -109,10 +109,9 @@ export declare function normalizeGitUrlForCompare(url: string): string;
109
109
  /**
110
110
  * Read `remote.origin.url` from an existing clone using `git config --get`.
111
111
  *
112
- * Returns `null` if the config key is absent, the spawn fails, or the
113
- * directory isn't a git repository. The caller decides what a missing
114
- * remote means for its threat model — for cloneOrPull, a missing remote
115
- * on an existing clone is treated as a refuse-to-pull condition.
112
+ * Returns `null` only when the key is absent (exit 1) or empty. Timeouts and
113
+ * every other git failure throw, so a lock or spawn error is not treated as
114
+ * a missing origin.
116
115
  */
117
116
  export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Promise<string | null>;
118
117
  /**
@@ -129,7 +128,7 @@ export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Pro
129
128
  * Exported so the comparison logic is testable in isolation against any
130
129
  * tmpdir-based fixture, without needing to populate CLONE_ROOT.
131
130
  */
132
- export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number): Promise<void>;
131
+ export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number, knownOriginUrl?: string): Promise<void>;
133
132
  /**
134
133
  * Clone or pull a git repository.
135
134
  *
@@ -14,7 +14,7 @@ import { branchSlug } from '../storage/branch-index.js';
14
14
  import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
15
15
  import { validateGitUrl } from '../core/net/url-guard.js';
16
16
  import { assertDirectoryOwnerAndPermissions, quarantineAutoSyncPartial, } from '../core/auto-sync/path-security.js';
17
- import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
17
+ import { getAutoSyncRepoIdentity, validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
18
18
  export { validateGitUrl };
19
19
  /**
20
20
  * Root directory for all cloned repositories. Targets must resolve inside this.
@@ -272,13 +272,21 @@ export function normalizeGitUrlForCompare(url) {
272
272
  return trimmed.toLowerCase();
273
273
  }
274
274
  }
275
+ /** Same allowlisted repo across SSH and HTTPS, ignoring a trailing `.git`. */
276
+ function sameAllowlistedAutoSyncRepo(left, right) {
277
+ try {
278
+ return getAutoSyncRepoIdentity(left) === getAutoSyncRepoIdentity(right);
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ }
275
284
  /**
276
285
  * Read `remote.origin.url` from an existing clone using `git config --get`.
277
286
  *
278
- * Returns `null` if the config key is absent, the spawn fails, or the
279
- * directory isn't a git repository. The caller decides what a missing
280
- * remote means for its threat model — for cloneOrPull, a missing remote
281
- * on an existing clone is treated as a refuse-to-pull condition.
287
+ * Returns `null` only when the key is absent (exit 1) or empty. Timeouts and
288
+ * every other git failure throw, so a lock or spawn error is not treated as
289
+ * a missing origin.
282
290
  */
283
291
  export async function getRemoteOriginUrl(cwd, timeoutMs) {
284
292
  try {
@@ -286,9 +294,12 @@ export async function getRemoteOriginUrl(cwd, timeoutMs) {
286
294
  return stdout.trim() || null;
287
295
  }
288
296
  catch (error) {
289
- if (error.message.includes('timed out'))
297
+ const message = error.message ?? '';
298
+ if (message.includes('timed out'))
290
299
  throw error;
291
- return null;
300
+ if (message.includes('failed (exit code 1)'))
301
+ return null;
302
+ throw error;
292
303
  }
293
304
  }
294
305
  /**
@@ -305,8 +316,8 @@ export async function getRemoteOriginUrl(cwd, timeoutMs) {
305
316
  * Exported so the comparison logic is testable in isolation against any
306
317
  * tmpdir-based fixture, without needing to populate CLONE_ROOT.
307
318
  */
308
- export async function assertRemoteMatchesRequestedUrl(targetDir, requestedUrl, timeoutMs) {
309
- const remoteUrl = await getRemoteOriginUrl(targetDir, timeoutMs);
319
+ export async function assertRemoteMatchesRequestedUrl(targetDir, requestedUrl, timeoutMs, knownOriginUrl) {
320
+ const remoteUrl = knownOriginUrl ?? (await getRemoteOriginUrl(targetDir, timeoutMs));
310
321
  if (remoteUrl === null) {
311
322
  throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`);
312
323
  }
@@ -477,17 +488,39 @@ export async function cloneOrPull(url, targetDir, onProgress, options) {
477
488
  await fs.mkdir(path.dirname(safeTarget), { recursive: true });
478
489
  await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
479
490
  await assertPreRealpathContainment(cloneRoot, safeTarget);
480
- const exists = await fs.access(path.join(safeTarget, '.git')).then(() => true, () => false);
491
+ let exists = await fs.access(path.join(safeTarget, '.git')).then(() => true, () => false);
492
+ let originUrl = null;
493
+ if (exists && options?.allowAutoSyncSsh) {
494
+ originUrl = await getRemoteOriginUrl(safeTarget, options?.timeoutMs);
495
+ if (!originUrl) {
496
+ if (options.quarantineRoot) {
497
+ await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot);
498
+ exists = false;
499
+ }
500
+ else {
501
+ throw new Error(`Existing clone at ${safeTarget} has no remote.origin — remove ${safeTarget} and retry`);
502
+ }
503
+ }
504
+ }
481
505
  const targetExists = await fs.access(safeTarget).then(() => true, () => false);
482
506
  if (exists) {
483
507
  if (options?.allowedCloneRoot) {
484
508
  await assertNoSymlinkPath(cloneRoot, path.join(safeTarget, '.git'), true);
485
509
  }
486
510
  await assertPostRealpathContainment(cloneRoot, safeTarget);
511
+ let originForCompare = originUrl;
512
+ if (originUrl &&
513
+ normalizeGitUrlForCompare(originUrl) !== normalizeGitUrlForCompare(url) &&
514
+ sameAllowlistedAutoSyncRepo(originUrl, url)) {
515
+ await runGit(['remote', 'set-url', 'origin', url], safeTarget, {
516
+ timeoutMs: options?.timeoutMs,
517
+ });
518
+ originForCompare = url;
519
+ }
487
520
  // Confirm the existing clone is actually the same repository the caller
488
521
  // requested. Without this check, a pull would silently succeed against
489
522
  // whatever remote the dir was originally cloned from.
490
- await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs);
523
+ await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs, originForCompare ?? undefined);
491
524
  onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
492
525
  const runGitImpl = options?.runGitForTest ?? runGit;
493
526
  const gitOpts = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.36",
3
+ "version": "1.6.13-rc.37",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -341,6 +341,12 @@ const FILESYSTEM = [
341
341
  // 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
342
342
  // disk — against a 30 s testTimeout.
343
343
  'test/unit/source-control-bytes.test.ts',
344
+ // Auto-sync reads a cloned `.gitnexusrc` through the symlink/hard-link guard,
345
+ // and clone recovery uses real `git` plus temp dirs. Ubuntu coverage alone
346
+ // would never create the Windows file symlink (`type: 'file'`) or run the
347
+ // git-config failure path on windows-latest / macos-latest.
348
+ 'test/unit/gitnexus-rc-embeddings.test.ts',
349
+ 'test/unit/git-clone.test.ts',
344
350
  ];
345
351
 
346
352
  const ALL_CROSS_PLATFORM = [