Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ npm-debug.log*

# Testing
coverage/
.tmp-test/
gitnexus/.tmp-test/

# Misc
*.local
Expand Down Expand Up @@ -121,3 +123,4 @@ local_docs/
!.agents/plugins/marketplace.json
.context/
gitnexus/web/
/log/
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,42 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed

</details>

<details>
<summary><strong>Keep remote repositories indexed with <code>gitnexus watch</code></strong></summary>

`gitnexus watch` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session.

```bash
# 1. Create the config once. It never overwrites an existing file.
gitnexus watch init

# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it.
gitnexus watch start # `gitnexus watch` is equivalent
gitnexus watch status
gitnexus watch restart # Required after config changes
gitnexus watch stop
```

`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration:

```yaml
sync_interval_minutes: 10
projects:
- local_path: /absolute/path/to/clones
branches: [main, master]
remote_urls:
- git@github.com:owner/repo.git
```

- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path.
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Add `group_name` only after creating that group with `gitnexus group create <name>`.

See the [full watch configuration and runtime reference](gitnexus/README.md#gitnexus-watch) for concurrency, timeouts, failure thresholds, and runtime files.

</details>

<details>
<summary><strong>Repository groups</strong> (multi-repo / monorepo service tracking)</summary>

Expand Down
6 changes: 3 additions & 3 deletions eslint-rules/require-safe-parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
*
* False-positive suppression:
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
* `URL`, `marked`, `Number`).
* `URL`, `marked`, `Number`, `path`).
* - Skips calls whose first argument is a string-literal (grammar-load smoke
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
* - Skips the `safe-parse.ts` helper itself.
*/

const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']);

export default {
meta: {
Expand Down Expand Up @@ -74,7 +74,7 @@ export default {
// Receiver-text-shape skip: anything matching well-known JS APIs that
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
if (
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
/^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) ||
/\bjson\.parse\b/i.test(receiverText)
) {
return;
Expand Down
22 changes: 22 additions & 0 deletions gitnexus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
gitnexus watch [init|start|restart|stop|status] # Control auto-sync from GITNEXUS_HOME/watch_config.yml
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI
gitnexus index # Register an existing .gitnexus/ folder into the global registry
Expand Down Expand Up @@ -274,6 +275,27 @@ gitnexus group status <name> # Check staleness of repos in a group
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
```

### `gitnexus watch`

`gitnexus watch` is the explicit long-running auto-sync entrypoint. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus watch init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus watch` is the same as `gitnexus watch start`; `restart`, `stop`, and `status` manage the same `GITNEXUS_HOME` instance. `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.lock` prevents multiple watch processes for one home, `watch.pid` and `watch.status.json` expose process state, and `quarantine/` stores partial clone output.

```yaml
sync_interval_minutes: 10
max_concurrency: 1
repo_git_timeout: 10s
analyze_failure_threshold: 3
projects:
- local_path: /abs/path/to/repos
branches: [master, main]
group_name: back_end
remote_urls:
- git@github.com:owner/repo.git
- git@gitlab.com:group/repo.git
- 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. `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. `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. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and skips repeated failing analyze runs for the same repo branch until the auto-sync state is cleared. 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`.

> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.

## Remote Embeddings
Expand Down
1 change: 1 addition & 0 deletions gitnexus/src/cli/help-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
watch: 'help.command.watch.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',
Expand Down
2 changes: 2 additions & 0 deletions gitnexus/src/cli/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export const en = {
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.watch.description':
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
'help.command.analyze.description': 'Index a repository (full analysis)',
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
Expand Down
2 changes: 2 additions & 0 deletions gitnexus/src/cli/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export const zhCN = {
'一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
'help.command.watch.description':
'控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
Expand Down
19 changes: 19 additions & 0 deletions gitnexus/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ program
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));

program
.command('watch [action]')
.description(
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
)
.addHelpText(
'after',
[
'',
'Actions: init, start (default), restart, stop, status',
'Configuration: GITNEXUS_HOME/watch_config.yml',
'Runtime files: GITNEXUS_HOME/watch/watch.pid, watch.lock, watch.status.json, auto-sync-state.json',
'Writes: GITNEXUS_HOME/watch/project_commit_info.txt',
'Remote URLs: only git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, and git@gitee.com:owner/repo.git are allowed.',
'Runs once immediately, then repeats on sync_interval_minutes.',
].join('\n'),
)
.action(createLazyAction(() => import('./watch.js'), 'watchCommand'));

// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
// before it overwrites the var, so the postAction hook can restore it. The
// analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo
Expand Down
103 changes: 103 additions & 0 deletions gitnexus/src/cli/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {
getAutoSyncConfigPath,
readAutoSyncWatchStatus,
startAutoSyncWatch,
stopAutoSyncWatch,
type WatchStatusRecord,
} from '../core/auto-sync/index.js';

export async function watchCommand(action = 'start'): Promise<void> {
if (action === 'init') {
await initWatchConfig();
return;
}
if (action === 'status') {
printStatus(await readAutoSyncWatchStatus());
return;
}
if (action === 'stop') {
await stopAutoSyncWatch();
return;
}
if (action === 'restart') {
const stopped = await stopAutoSyncWatch();
if (!stopped) {
process.exitCode = 1;
return;
}
await startWatchProcess();
return;
}
if (action !== 'start') {
process.stderr.write(`[auto-sync] Unknown watch action: ${action}\n`);
process.exitCode = 1;
return;
}
await startWatchProcess();
}

async function startWatchProcess(): Promise<void> {
const handle = await startAutoSyncWatch();
if (!handle) {
process.exitCode = 1;
return;
}

const stop = () => {
void handle.stop().finally(() => {
process.stderr.write('[auto-sync] Watch stopped.\n');
process.exit(0);
});
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
}

function printStatus(status: WatchStatusRecord): void {
const parts = [`state=${status.state}`];
if (status.pid) parts.push(`pid=${status.pid}`);
if (status.configPath) parts.push(`config=${status.configPath}`);
if (status.message) parts.push(`message=${status.message}`);
parts.push(`updated_at=${status.updatedAt}`);
process.stdout.write(`${parts.join(' ')}\n`);
}

async function initWatchConfig(): Promise<void> {
const configPath = getAutoSyncConfigPath();
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
defaultSyncConfig(path.resolve(path.dirname(configPath), 'repo')),
{
flag: 'wx',
},
);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`);
process.exitCode = 1;
return;
}
throw err;
}
process.stdout.write(`[auto-sync] Created ${configPath}\n`);
}

function defaultSyncConfig(localPath: string): string {
return [
'sync_interval_minutes: 10',
'max_concurrency: 1',
'repo_git_timeout: 10s',
'analyze_failure_threshold: 3',
'projects:',
` - local_path: ${localPath}`,
' branches: [master, main]',
' group_name: back_end',
' remote_urls:',
' - git@github.com:owner/repo.git',
'',
].join('\n');
}
Loading