gitnexus 1.6.11-rc.35 → 1.6.11-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.
Files changed (48) hide show
  1. package/README.md +23 -0
  2. package/dist/cli/analyze-watch.d.ts +20 -0
  3. package/dist/cli/analyze-watch.js +375 -0
  4. package/dist/cli/analyze.js +1 -1
  5. package/dist/cli/auto-sync.d.ts +1 -0
  6. package/dist/cli/auto-sync.js +107 -0
  7. package/dist/cli/help-i18n.js +2 -0
  8. package/dist/cli/i18n/en.d.ts +5 -0
  9. package/dist/cli/i18n/en.js +5 -0
  10. package/dist/cli/i18n/resources.d.ts +10 -0
  11. package/dist/cli/i18n/zh-CN.d.ts +5 -0
  12. package/dist/cli/i18n/zh-CN.js +5 -0
  13. package/dist/cli/index.js +10 -0
  14. package/dist/cli/watch.d.ts +1 -20
  15. package/dist/cli/watch.js +5 -373
  16. package/dist/core/auto-sync/analysis-worker-launch.d.ts +30 -0
  17. package/dist/core/auto-sync/analysis-worker-launch.js +166 -0
  18. package/dist/core/auto-sync/config.d.ts +33 -0
  19. package/dist/core/auto-sync/config.js +320 -0
  20. package/dist/core/auto-sync/index.d.ts +6 -0
  21. package/dist/core/auto-sync/index.js +6 -0
  22. package/dist/core/auto-sync/path-security.d.ts +9 -0
  23. package/dist/core/auto-sync/path-security.js +251 -0
  24. package/dist/core/auto-sync/repo.d.ts +1 -0
  25. package/dist/core/auto-sync/repo.js +6 -0
  26. package/dist/core/auto-sync/runner.d.ts +44 -0
  27. package/dist/core/auto-sync/runner.js +428 -0
  28. package/dist/core/auto-sync/starter.d.ts +50 -0
  29. package/dist/core/auto-sync/starter.js +497 -0
  30. package/dist/core/auto-sync/state.d.ts +37 -0
  31. package/dist/core/auto-sync/state.js +119 -0
  32. package/dist/core/group/extractors/http-patterns/node.js +145 -8
  33. package/dist/core/group/extractors/http-route-extractor.js +56 -5
  34. package/dist/core/group/group-lock.js +5 -6
  35. package/dist/core/lbug/lbug-adapter.js +1 -14
  36. package/dist/server/analyze-worker-protocol.d.ts +6 -1
  37. package/dist/server/analyze-worker.d.ts +1 -0
  38. package/dist/server/analyze-worker.js +38 -20
  39. package/dist/server/api.js +2 -2
  40. package/dist/server/git-clone.d.ts +39 -10
  41. package/dist/server/git-clone.js +254 -56
  42. package/dist/storage/file-lock.d.ts +15 -0
  43. package/dist/storage/file-lock.js +155 -0
  44. package/dist/storage/repo-manager.js +10 -16
  45. package/dist/utils/process-identity.d.ts +2 -0
  46. package/dist/utils/process-identity.js +35 -0
  47. package/package.json +1 -1
  48. package/skills/gitnexus-cli.md +1 -1
package/README.md CHANGED
@@ -249,6 +249,7 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
249
249
  gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
250
250
  gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
251
251
  gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
252
+ gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml
252
253
  gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
253
254
  gitnexus serve # Start local HTTP server (multi-repo) for web UI
254
255
  gitnexus index # Register an existing .gitnexus/ folder into the global registry
@@ -309,6 +310,28 @@ and `serve` processes periodically check for a newly published index and reopen
309
310
  it without a restart. MCP checks are throttled to once every five seconds, so a
310
311
  tool call before the next check can briefly use the previous index.
311
312
 
313
+ ### `gitnexus auto-sync`
314
+
315
+ `gitnexus auto-sync` is a different product from `gitnexus analyze --watch`. It is the explicit long-running auto-sync entrypoint that clones or pulls configured remotes. `gitnexus watch` is reserved and does not start either job: it prints this split. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus auto-sync init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus auto-sync` is the same as `gitnexus auto-sync start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple auto-sync processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, `watch.stop.<ownerId>.json` is a temporary owner-fenced stop request, and `quarantine/` stores partial clone output before entries are removed after 14 days, keeping at most the five newest entries per repository regardless of age. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no auto-sync process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`.
316
+
317
+ ```yaml
318
+ sync_interval_minutes: 10
319
+ max_concurrency: 1
320
+ repo_git_timeout: 10s
321
+ analyze_timeout: 5m
322
+ analyze_failure_threshold: 3
323
+ projects:
324
+ - local_path: /abs/path/to/repos
325
+ branches: [master, main]
326
+ overwrite_local_changes: false
327
+ remote_urls:
328
+ - git@github.com:owner/repo.git
329
+ - git@gitlab.com:group/repo.git
330
+ - git@gitee.com:owner/repo.git
331
+ ```
332
+
333
+ `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, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. 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. `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 and commit; a new commit 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`.
334
+
312
335
  GraphQL contract matching is opt-in in the group's `group.yaml`:
313
336
 
314
337
  ```yaml
@@ -0,0 +1,20 @@
1
+ import { type AnalyzeOptions as CoreAnalyzeOptions } from '../core/run-analyze.js';
2
+ import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
3
+ import type { AnalyzeOptions } from './analyze-options.js';
4
+ import { type WatchRefreshError } from './watch-queue.js';
5
+ export type WatchCliOptions = AnalyzeOptions;
6
+ export declare function isRelevantWatchPath(filePath: string): boolean;
7
+ export interface WatchEnvironmentBaseline {
8
+ readonly maxFileSize: string | undefined;
9
+ readonly workerTimeout: string | undefined;
10
+ readonly verbose: string | undefined;
11
+ }
12
+ export declare function resolveWatchOptions(repoPath: string, cli: WatchCliOptions, baseline: WatchEnvironmentBaseline, reportIgnoredConfig?: (names: readonly string[]) => void): Promise<CoreAnalyzeOptions>;
13
+ export interface WatchFileLoop {
14
+ readonly waitForIdle: () => Promise<void>;
15
+ readonly close: () => Promise<void>;
16
+ }
17
+ export declare function shouldStopAfterWatchRefreshFailure(error: unknown, paths: readonly string[]): boolean;
18
+ /** Start the real filesystem watcher with bounded, serialized refreshes. */
19
+ export declare function startWatchFileLoop(repoPath: string, debounceMs: number, refresh: (paths: readonly string[]) => Promise<void>, onError: WatchRefreshError, onWatcherError?: (error: unknown) => void): Promise<WatchFileLoop>;
20
+ export declare function watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap: AnalyzerRunnerIdentity, inputPath?: string, cliOptions?: WatchCliOptions): Promise<void>;
@@ -0,0 +1,375 @@
1
+ /** Local incremental watch (`gitnexus analyze --watch`). Remote auto-sync lives in `auto-sync.ts`. */
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ import { watch } from 'chokidar';
5
+ import { createWatchIgnorePredicate } from '../config/ignore-service.js';
6
+ import { analyzeFailureMayHaveMutatedLiveIndex, runFullAnalysis, } from '../core/run-analyze.js';
7
+ import { getGitRoot, hasGitDir } from '../storage/git.js';
8
+ import { GITNEXUS_DIR } from '../storage/repo-meta.js';
9
+ import { loadAnalyzeConfigStrict, mergeAnalyzeOptions, validateBranchName, } from './analyze-config.js';
10
+ import { ensureHeap } from './analyze.js';
11
+ import { cliError, cliInfo, cliWarn } from './cli-message.js';
12
+ import { WATCH_FULL_REFRESH_PATH, WatchRefreshQueue, } from './watch-queue.js';
13
+ const DEFAULT_DEBOUNCE_MS = 300;
14
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
15
+ const MAX_FILE_SIZE_KB = 32 * 1024;
16
+ const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']);
17
+ function posixWatchPath(filePath) {
18
+ return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '');
19
+ }
20
+ export function isRelevantWatchPath(filePath) {
21
+ const normalized = posixWatchPath(filePath);
22
+ return (normalized.length > 0 &&
23
+ normalized !== '.' &&
24
+ !normalized.startsWith('../') &&
25
+ !path.posix.isAbsolute(normalized) &&
26
+ !path.win32.isAbsolute(filePath));
27
+ }
28
+ function isIgnoreControlPath(filePath) {
29
+ const normalized = posixWatchPath(filePath);
30
+ return normalized === '.gitignore' || normalized === '.gitnexusignore';
31
+ }
32
+ function isConfigControlPath(filePath) {
33
+ return posixWatchPath(filePath) === '.gitnexusrc';
34
+ }
35
+ function isAnalyzerOwnedWatchPath(filePath) {
36
+ const normalized = posixWatchPath(filePath).replace(/\/+$/, '');
37
+ return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`);
38
+ }
39
+ function repoRelativeWatchPath(repoPath, candidate) {
40
+ const relative = path.relative(repoPath, candidate).replace(/\\/g, '/');
41
+ if (!relative || relative.startsWith('../') || path.isAbsolute(relative))
42
+ return null;
43
+ return relative;
44
+ }
45
+ function setEnvironment(name, value) {
46
+ if (value === undefined)
47
+ delete process.env[name];
48
+ else
49
+ process.env[name] = value;
50
+ }
51
+ function positiveInteger(value, flag, maximum) {
52
+ if (value === undefined)
53
+ return undefined;
54
+ const parsed = Number(value);
55
+ if (!Number.isInteger(parsed) || parsed < 1)
56
+ throw new Error(`${flag} must be a positive integer`);
57
+ if (maximum !== undefined && parsed > maximum) {
58
+ throw new Error(`${flag} must not exceed ${maximum}`);
59
+ }
60
+ return parsed;
61
+ }
62
+ export async function resolveWatchOptions(repoPath, cli, baseline, reportIgnoredConfig = () => { }) {
63
+ const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {};
64
+ const merged = mergeAnalyzeOptions(cli, config);
65
+ const unsupported = [
66
+ ['--force', cli.force],
67
+ ['--repair-fts', cli.repairFts],
68
+ ['--embeddings', cli.embeddings],
69
+ ['--drop-embeddings', cli.dropEmbeddings],
70
+ ['--skills', cli.skills],
71
+ ['--default-branch', cli.defaultBranch],
72
+ ['--skip-agents-md', cli.skipAgentsMd],
73
+ ['--skip-skills', cli.skipSkills],
74
+ ['--no-stats', cli.stats === false],
75
+ ['--self-commit', cli.selfCommit],
76
+ ['--index-only', cli.indexOnly],
77
+ ['--skip-git', cli.skipGit],
78
+ ['--spring-actuator', cli.springActuator],
79
+ ['walCheckpointThreshold', cli.walCheckpointThreshold],
80
+ ['embeddingThreads', cli.embeddingThreads],
81
+ ['embeddingBatchSize', cli.embeddingBatchSize],
82
+ ['embeddingSubBatchSize', cli.embeddingSubBatchSize],
83
+ ['embeddingDevice', cli.embeddingDevice],
84
+ ['embeddingBaseUrl', cli.embeddingBaseUrl],
85
+ ['embeddingModel', cli.embeddingModel],
86
+ ['--embedding-auth-token', cli.embeddingAuthToken],
87
+ ['--embedding-dims', cli.embeddingDims],
88
+ ].filter(([, value]) => value !== undefined && value !== false);
89
+ if (unsupported.length > 0) {
90
+ throw new Error(`analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`);
91
+ }
92
+ reportIgnoredConfig([
93
+ ['embeddings', config.embeddings],
94
+ ['dropEmbeddings', config.dropEmbeddings],
95
+ ['defaultBranch', config.defaultBranch],
96
+ ['skipAgentsMd', config.skipAgentsMd !== undefined],
97
+ ['skipSkills', config.skipSkills !== undefined],
98
+ ['stats', config.stats !== undefined],
99
+ ['springActuator', config.springActuator],
100
+ ['walCheckpointThreshold', config.walCheckpointThreshold],
101
+ ['embeddingThreads', config.embeddingThreads],
102
+ ['embeddingBatchSize', config.embeddingBatchSize],
103
+ ['embeddingSubBatchSize', config.embeddingSubBatchSize],
104
+ ['embeddingDevice', config.embeddingDevice],
105
+ ['embeddingBaseUrl', config.embeddingBaseUrl],
106
+ ['embeddingModel', config.embeddingModel],
107
+ ]
108
+ .filter(([, value]) => value !== undefined && value !== false)
109
+ .map(([name]) => String(name)));
110
+ const branch = merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch');
111
+ const workerPoolSize = positiveInteger(merged.workers, '--workers');
112
+ const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout');
113
+ const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB);
114
+ setEnvironment('GITNEXUS_MAX_FILE_SIZE', maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize));
115
+ if (workerTimeoutSeconds !== undefined) {
116
+ process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000);
117
+ }
118
+ else {
119
+ setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout);
120
+ }
121
+ setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose);
122
+ return {
123
+ pdg: merged.pdg,
124
+ branch,
125
+ registryName: merged.name,
126
+ allowDuplicateName: merged.allowDuplicateName,
127
+ workerPoolSize,
128
+ fetchWrappers: merged.fetchWrappers,
129
+ skipAgentsMd: true,
130
+ skipSkills: true,
131
+ noStats: true,
132
+ atomicIncremental: process.platform !== 'win32',
133
+ };
134
+ }
135
+ function refreshSummary(result, observedPaths, durationMs, lastSuccessfulRefreshAt) {
136
+ const measured = result.incrementalStats;
137
+ const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length);
138
+ const reparsed = measured?.reparsedFiles ??
139
+ (typeof result.pipelineResult?.reparsedFileCount === 'number'
140
+ ? result.pipelineResult.reparsedFileCount
141
+ : 0);
142
+ const dependents = measured?.affectedDependents ?? 0;
143
+ const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full');
144
+ return (`Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` +
145
+ `${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` +
146
+ `last success ${lastSuccessfulRefreshAt}`);
147
+ }
148
+ async function waitUntilReady(watcher) {
149
+ await new Promise((resolve, reject) => {
150
+ const ready = () => {
151
+ watcher.off('error', failed);
152
+ resolve();
153
+ };
154
+ const failed = (error) => {
155
+ watcher.off('ready', ready);
156
+ reject(error);
157
+ };
158
+ watcher.once('ready', ready);
159
+ watcher.once('error', failed);
160
+ });
161
+ }
162
+ class WatchControlReloadError extends Error {
163
+ constructor(cause) {
164
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
165
+ this.name = 'WatchControlReloadError';
166
+ }
167
+ }
168
+ export function shouldStopAfterWatchRefreshFailure(error, paths) {
169
+ return (paths.length > 0 &&
170
+ !(error instanceof WatchControlReloadError) &&
171
+ analyzeFailureMayHaveMutatedLiveIndex(error));
172
+ }
173
+ /** Start the real filesystem watcher with bounded, serialized refreshes. */
174
+ export async function startWatchFileLoop(repoPath, debounceMs, refresh, onError, onWatcherError = (error) => onError(error, [])) {
175
+ let ignorePath = await createWatchIgnorePredicate(repoPath);
176
+ let ignoreControlValid = true;
177
+ const queue = new WatchRefreshQueue(async (paths) => {
178
+ if (paths.some(isIgnoreControlPath) || !ignoreControlValid) {
179
+ const retryingInvalidControls = !ignoreControlValid;
180
+ try {
181
+ ignorePath = await createWatchIgnorePredicate(repoPath);
182
+ ignoreControlValid = true;
183
+ watcher.add(repoPath);
184
+ }
185
+ catch (error) {
186
+ ignoreControlValid = false;
187
+ throw new WatchControlReloadError(retryingInvalidControls
188
+ ? new Error('Ignore controls remain invalid; fix them before indexing more changes.', {
189
+ cause: error,
190
+ })
191
+ : error);
192
+ }
193
+ }
194
+ await refresh(paths);
195
+ }, onError, debounceMs, {
196
+ maxWaitMs: Math.max(2_000, debounceMs * 10),
197
+ maxPendingPaths: 1_000,
198
+ holdEventsUntilInitialRefresh: true,
199
+ isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath),
200
+ });
201
+ const watcher = watch(repoPath, {
202
+ ignoreInitial: true,
203
+ atomic: true,
204
+ followSymlinks: false,
205
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
206
+ ignored: (candidate, stats) => {
207
+ const relative = repoRelativeWatchPath(repoPath, candidate);
208
+ if (relative !== null && isAnalyzerOwnedWatchPath(relative))
209
+ return true;
210
+ if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) {
211
+ return false;
212
+ }
213
+ return ignorePath(candidate, stats?.isDirectory() ?? false);
214
+ },
215
+ });
216
+ watcher.on('all', (event, changedPath) => {
217
+ if (event !== 'add' && event !== 'change' && event !== 'unlink')
218
+ return;
219
+ const relative = repoRelativeWatchPath(repoPath, changedPath);
220
+ if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) {
221
+ queue.enqueue(relative);
222
+ }
223
+ });
224
+ watcher.on('error', (error) => {
225
+ // Chokidar can surface a transient EPERM on Windows while an ignored
226
+ // analyzer-owned path is replaced. Re-arm the root and force one bounded
227
+ // catch-up refresh so a missed event cannot leave the graph stale. Other
228
+ // watcher errors may mean coverage was lost and remain fatal.
229
+ if (TRANSIENT_WATCH_ERROR_CODES.has(error.code ?? '')) {
230
+ watcher.add(repoPath);
231
+ queue.enqueue(WATCH_FULL_REFRESH_PATH);
232
+ return;
233
+ }
234
+ onWatcherError(error);
235
+ });
236
+ try {
237
+ await waitUntilReady(watcher);
238
+ await queue.runInitial();
239
+ }
240
+ catch (error) {
241
+ await watcher.close();
242
+ await queue.close();
243
+ throw error;
244
+ }
245
+ return {
246
+ waitForIdle: () => queue.waitForIdle(),
247
+ close: async () => {
248
+ await watcher.close();
249
+ await queue.close();
250
+ },
251
+ };
252
+ }
253
+ export async function watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, cliOptions = {}) {
254
+ if (await ensureHeap({ cleanForwardedTermination: true }))
255
+ return;
256
+ const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
257
+ if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) {
258
+ cliError(' gitnexus analyze --watch requires a Git repository.');
259
+ process.exitCode = 1;
260
+ return;
261
+ }
262
+ const repoPath = await fs.realpath(requestedRepoPath);
263
+ const baselineEnvironment = {
264
+ maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE,
265
+ workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS,
266
+ verbose: process.env.GITNEXUS_VERBOSE,
267
+ };
268
+ try {
269
+ let ignoredConfigSignature;
270
+ const reportIgnoredConfig = (names) => {
271
+ const signature = [...names].sort().join(',');
272
+ if (signature === ignoredConfigSignature)
273
+ return;
274
+ ignoredConfigSignature = signature;
275
+ if (names.length > 0) {
276
+ cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`);
277
+ }
278
+ };
279
+ let debounceMs;
280
+ let analyzeOptions;
281
+ try {
282
+ debounceMs =
283
+ positiveInteger(cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS), '--debounce', MAX_TIMER_DELAY_MS) ?? DEFAULT_DEBOUNCE_MS;
284
+ analyzeOptions = await resolveWatchOptions(repoPath, cliOptions, baselineEnvironment, reportIgnoredConfig);
285
+ }
286
+ catch (error) {
287
+ cliError(` ${error instanceof Error ? error.message : String(error)}`);
288
+ process.exitCode = 1;
289
+ return;
290
+ }
291
+ let stopWatching;
292
+ const stopped = new Promise((resolve) => {
293
+ stopWatching = resolve;
294
+ });
295
+ const stop = () => stopWatching();
296
+ process.once('SIGINT', stop);
297
+ process.once('SIGTERM', stop);
298
+ try {
299
+ let loop;
300
+ let fatalRefreshError;
301
+ let configControlValid = true;
302
+ let lastSuccessfulRefreshAt;
303
+ try {
304
+ loop = await startWatchFileLoop(repoPath, debounceMs, async (paths) => {
305
+ if (paths.some(isConfigControlPath) || !configControlValid) {
306
+ const retryingInvalidConfig = !configControlValid;
307
+ try {
308
+ analyzeOptions = await resolveWatchOptions(repoPath, cliOptions, baselineEnvironment, reportIgnoredConfig);
309
+ configControlValid = true;
310
+ }
311
+ catch (error) {
312
+ configControlValid = false;
313
+ throw new WatchControlReloadError(retryingInvalidConfig
314
+ ? new Error('Configuration remains invalid; fix it before indexing more changes.', {
315
+ cause: error,
316
+ })
317
+ : error);
318
+ }
319
+ }
320
+ const startedAt = Date.now();
321
+ const result = await runFullAnalysis(repoPath, analyzeOptions, {
322
+ onProgress: () => { },
323
+ onLog: process.env.GITNEXUS_VERBOSE === '1'
324
+ ? (message) => cliInfo(` ${message}`)
325
+ : undefined,
326
+ }, runnerIdentityAtBootstrap);
327
+ lastSuccessfulRefreshAt = new Date().toISOString();
328
+ if (paths.length === 0) {
329
+ cliInfo(result.alreadyUpToDate
330
+ ? `Watching ${repoPath}; index is up to date.`
331
+ : `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`);
332
+ }
333
+ else {
334
+ cliInfo(refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt));
335
+ }
336
+ }, (error, paths) => {
337
+ const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : '';
338
+ if (shouldStopAfterWatchRefreshFailure(error, paths)) {
339
+ fatalRefreshError = error;
340
+ cliError(`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
341
+ 'Watch mode is stopping because the live index may have been updated in place.');
342
+ stopWatching();
343
+ return;
344
+ }
345
+ const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet';
346
+ cliWarn(`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
347
+ `Retry scheduled; last success ${lastSuccess}.`);
348
+ }, (error) => {
349
+ fatalRefreshError = error;
350
+ cliError(`Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` +
351
+ 'Watch mode is stopping.');
352
+ stopWatching();
353
+ });
354
+ }
355
+ catch (error) {
356
+ cliError(` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`);
357
+ process.exitCode = 1;
358
+ return;
359
+ }
360
+ await stopped;
361
+ await loop.close();
362
+ if (fatalRefreshError !== undefined)
363
+ process.exitCode = 1;
364
+ }
365
+ finally {
366
+ process.removeListener('SIGINT', stop);
367
+ process.removeListener('SIGTERM', stop);
368
+ }
369
+ }
370
+ finally {
371
+ setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize);
372
+ setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout);
373
+ setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose);
374
+ }
375
+ }
@@ -602,7 +602,7 @@ export const analyzeCommand = async (inputPath, options, runnerIdentityAtBootstr
602
602
  export const analyzeCommandWithRunnerIdentity = async (runnerIdentityAtBootstrap, inputPath, options) => analyzeCommand(inputPath, options, runnerIdentityAtBootstrap);
603
603
  export async function analyzeOrWatchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options = {}) {
604
604
  if (options.watch) {
605
- const { watchCommandWithRunnerIdentity } = await import('./watch.js');
605
+ const { watchCommandWithRunnerIdentity } = await import('./analyze-watch.js');
606
606
  await watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options);
607
607
  return;
608
608
  }
@@ -0,0 +1 @@
1
+ export declare function autoSyncCommand(action?: string): Promise<void>;
@@ -0,0 +1,107 @@
1
+ /** Remote auto-sync CLI (`gitnexus auto-sync`). Local incremental watch lives in `analyze-watch.ts`. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getAutoSyncConfigPath, getAutoSyncMutexPath, readAutoSyncWatchStatus, resetAutoSyncState, startAutoSyncWatch, stopAutoSyncWatch, } from '../core/auto-sync/index.js';
5
+ export async function autoSyncCommand(action = 'start') {
6
+ if (action === 'init') {
7
+ await initWatchConfig();
8
+ return;
9
+ }
10
+ if (action === 'reset') {
11
+ if (!(await resetAutoSyncState())) {
12
+ process.stderr.write(`[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`);
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+ process.stdout.write('[auto-sync] Reset analysis state.\n');
17
+ return;
18
+ }
19
+ if (action === 'status') {
20
+ printStatus(await readAutoSyncWatchStatus());
21
+ return;
22
+ }
23
+ if (action === 'stop') {
24
+ if ((await stopAutoSyncWatch()) !== 'stopped')
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ if (action === 'restart') {
29
+ const result = await stopAutoSyncWatch();
30
+ if (result === 'refused' || result === 'timeout') {
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ await startWatchProcess();
35
+ return;
36
+ }
37
+ if (action !== 'start') {
38
+ process.stderr.write(`[auto-sync] Unknown auto-sync action: ${action}\n`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ await startWatchProcess();
43
+ }
44
+ async function startWatchProcess() {
45
+ const handle = await startAutoSyncWatch();
46
+ if (!handle) {
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ const stop = () => {
51
+ void handle.stop().then(() => {
52
+ process.stderr.write('[auto-sync] Watch stopped.\n');
53
+ process.exit(0);
54
+ }, (error) => {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`);
57
+ process.exit(1);
58
+ });
59
+ };
60
+ process.once('SIGINT', stop);
61
+ process.once('SIGTERM', stop);
62
+ }
63
+ function printStatus(status) {
64
+ const parts = [`state=${status.state}`];
65
+ if (status.pid)
66
+ parts.push(`pid=${status.pid}`);
67
+ if (status.configPath)
68
+ parts.push(`config=${status.configPath}`);
69
+ if (status.message)
70
+ parts.push(`message=${status.message}`);
71
+ parts.push(`updated_at=${status.updatedAt}`);
72
+ process.stdout.write(`${parts.join(' ')}\n`);
73
+ }
74
+ async function initWatchConfig() {
75
+ const configPath = getAutoSyncConfigPath();
76
+ try {
77
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
78
+ await fs.writeFile(configPath, defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')), {
79
+ flag: 'wx',
80
+ });
81
+ }
82
+ catch (err) {
83
+ if (err.code === 'EEXIST') {
84
+ process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ throw err;
89
+ }
90
+ process.stdout.write(`[auto-sync] Created ${configPath}\n`);
91
+ }
92
+ function defaultSyncConfig(localPath) {
93
+ return [
94
+ 'sync_interval_minutes: 10',
95
+ 'max_concurrency: 1',
96
+ 'repo_git_timeout: 10s',
97
+ 'analyze_timeout: 5m',
98
+ 'analyze_failure_threshold: 3',
99
+ 'projects:',
100
+ ` - local_path: ${localPath}`,
101
+ ' branches: [master, main]',
102
+ ' overwrite_local_changes: false',
103
+ ' remote_urls:',
104
+ ' - git@github.com:owner/repo.git',
105
+ '',
106
+ ].join('\n');
107
+ }
@@ -10,6 +10,8 @@ const COMMAND_DESCRIPTION_KEYS = {
10
10
  '': 'help.description.root',
11
11
  setup: 'help.command.setup.description',
12
12
  uninstall: 'help.command.uninstall.description',
13
+ watch: 'help.command.watch.description',
14
+ 'auto-sync': 'help.command.autoSync.description',
13
15
  analyze: 'help.command.analyze.description',
14
16
  index: 'help.command.index.description',
15
17
  serve: 'help.command.serve.description',
@@ -125,6 +125,11 @@ export declare const en: {
125
125
  readonly 'help.option.version': "output the version number";
126
126
  readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex";
127
127
  readonly 'help.command.uninstall.description': "Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors";
128
+ readonly 'help.command.autoSync.description': "Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml";
129
+ 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.";
130
+ readonly 'help.command.watch.description': "Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes";
131
+ 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";
132
+ 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";
128
133
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
129
134
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
130
135
  readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
@@ -127,6 +127,11 @@ export const en = {
127
127
  'help.option.version': 'output the version number',
128
128
  'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
129
129
  'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
130
+ 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
131
+ '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.',
132
+ 'help.command.watch.description': 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
133
+ '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',
134
+ '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',
130
135
  'help.command.analyze.description': 'Index a repository (full analysis)',
131
136
  'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
132
137
  'help.command.serve.description': 'Start local HTTP server for web UI connection',
@@ -126,6 +126,11 @@ export declare const cliResources: {
126
126
  readonly 'help.option.version': "output the version number";
127
127
  readonly 'help.command.setup.description': "One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex";
128
128
  readonly 'help.command.uninstall.description': "Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors";
129
+ readonly 'help.command.autoSync.description': "Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml";
130
+ 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.";
131
+ readonly 'help.command.watch.description': "Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes";
132
+ 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";
133
+ 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";
129
134
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
130
135
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
131
136
  readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
@@ -391,6 +396,11 @@ export declare const cliResources: {
391
396
  'help.option.version': string;
392
397
  'help.command.setup.description': string;
393
398
  'help.command.uninstall.description': string;
399
+ 'help.command.autoSync.description': string;
400
+ 'help.autoSync.details': string;
401
+ 'help.command.watch.description': string;
402
+ 'help.watch.details': string;
403
+ 'error.watch.ambiguous': string;
394
404
  'help.command.analyze.description': string;
395
405
  'help.command.index.description': string;
396
406
  'help.command.serve.description': string;
@@ -125,6 +125,11 @@ export declare const zhCN: {
125
125
  'help.option.version': string;
126
126
  'help.command.setup.description': string;
127
127
  'help.command.uninstall.description': string;
128
+ 'help.command.autoSync.description': string;
129
+ 'help.autoSync.details': string;
130
+ 'help.command.watch.description': string;
131
+ 'help.watch.details': string;
132
+ 'error.watch.ambiguous': string;
128
133
  'help.command.analyze.description': string;
129
134
  'help.command.index.description': string;
130
135
  'help.command.serve.description': string;
@@ -125,6 +125,11 @@ export const zhCN = {
125
125
  'help.option.version': '输出版本号',
126
126
  'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
127
127
  'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
128
+ 'help.command.autoSync.description': '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
129
+ '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 重复。',
130
+ 'help.command.watch.description': '含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`',
131
+ 'help.watch.details': '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
132
+ 'error.watch.ambiguous': '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
128
133
  'help.command.analyze.description': '索引仓库(完整分析)',
129
134
  'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
130
135
  'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',