14 KiB
Multi-repo workspaces
Gortex can index multiple repositories into a single shared graph, enabling cross-repo symbol resolution, impact analysis, and navigation.
Workspace boundary
Every node and contract is keyed on a workspace slug, which is the hard graph boundary for cross-repo work. Two repos that should pair their contracts (an HTTP server and the client that calls it, a Kafka producer and its consumer, etc.) must declare the same workspace: in their .gortex.yaml — otherwise contract matching stops at the boundary and they look like orphans.
Slug resolution precedence (first match wins):
RepoEntry.workspacein~/.gortex/config.yaml— overrides everything, ideal for OSS / read-only repos where you don't want to leave an artifact in the treeworkspace:in the repo's own.gortex.yaml— the default for first-party repos- The repo prefix — fallback when neither is set, so each unconfigured repo gets its own isolated workspace
The same chain applies to the optional project: slug (a sub-bucket inside a workspace). The daemon loads every tracked repo into one shared graph; you scope a query to a single workspace or project at request time rather than at startup. Over the HTTP surface (gortex daemon start --http-addr ...) the /v1/graph route accepts ?project= and ?repo= to narrow the dump, so a typo'd value returns an empty result for that request instead of bringing the whole index up empty.
Configuration
Two-tier config hierarchy:
- Global config (
~/.gortex/config.yaml) — projects, repo lists, active project, reference tags - Workspace config (
.gortex.yamlper repo) — guards, excludes, local overrides
Excludes are layered — builtin → repo's own .gitignore → global → per-repo entry → workspace — with gitignore semantics. The repo's .gitignore is respected by default so you don't have to re-declare entries already curated for git; opt out per-workspace with respect_gitignore: false in .gortex.yaml. Use !pattern in a later layer to re-include something an earlier layer excluded. Beyond .gitignore, the index walk also honors per-directory .gortexignore files (Gortex's own ignore file, a sibling to .gitignore) and ripgrep's .ignore / .rgignore — each scoped to the directory that contains it.
# ~/.gortex/config.yaml
active_project: my-saas
exclude: # Applies to every tracked repo
- "**/*.generated.*"
- "node_modules/" # Already in the builtin baseline
repos:
- path: /home/user/projects/gortex
name: gortex
exclude: # Extra patterns just for this repo
- "results/**"
projects:
my-saas:
repos:
- path: /home/user/projects/frontend
name: frontend
ref: work
- path: /home/user/projects/backend
name: backend
ref: work
- path: /home/user/projects/shared-lib
name: shared-lib
ref: opensource
synthesize_external_calls: true (opt-in, default off — set in .gortex.yaml or the global config) makes the resolver synthesize placeholder nodes for calls into un-indexed external packages or sibling services, so call-chains keep the external hop instead of terminating at the indexed boundary.
Daemon tuning (optional)
The daemon's defaults handle typical workflows without configuration. These knobs exist for monorepos, branch-heavy workflows, or filesystems without fsnotify support.
# ~/.gortex/config.yaml (or per-repo .gortex.yaml)
watch:
debounce_ms: 150 # per-file patch debounce (default 150)
# Storm mode — when more than N events land within the window,
# switch from per-file debounced patching to a batched reconcile
# that defers cross-file resolver + search work until a quiet
# period has passed. Amortises the cost of bulk operations
# (rsync, npm install, branch checkout, bulk format-on-save,
# find-and-replace). Zero = disabled (default).
storm_threshold: 0 # 0 disables; try 50 on monorepos
storm_window_ms: 500
storm_quiet_period_ms: 500
Environment variables:
GORTEX_RECONCILE_INTERVAL— janitor tick that walks every tracked repo and runsIncrementalReindexagainst disk. Insurance against fsnotify gaps on NFS/SMB mounts, inotify watch-limit exhaustion, or daemon downtime where edits happened offline. Default1h;"0"or"off"disables; otherwise any Go duration string (e.g.,15m).- The daemon also watches each tracked repo's
.git/HEAD, so branch switches and rebases reconcile incrementally (viagit diff --name-status) rather than by re-indexing every changed file individually — no configuration needed. GORTEX_WARMUP_FULL_RETRACK=1— force every repo through a whole-repo re-track (evict + re-parse every file) on the next warm restart instead of the default scoped reconcile. An escape hatch for when the on-disk change census itself is suspect.GORTEX_WARMUP_FULL_RESOLVE=1— force the warm-restart master resolve to re-examine the whole graph instead of scoping to changed repos; also makes the resolver ignore the durable terminal-edge stamp and re-attempt every previously-given-up-on edge. Use when a scoped resolve is suspected of missing edges.GORTEX_WARMUP_FORCE_ENRICH=1— bypass the persisted per-repo enrichment-completion markers and re-run semantic enrichment for every repo on warm restart, even ones whose marker already matches HEAD on a clean tree.GORTEX_DAEMON_MEMLIMIT— standing soft memory limit installed at daemon boot, as a human size (4GiB,2048MiB,2G) oroff/0to disable. The daemon is a long-lived background service; a soft limit makes the GC pace against a ceiling and resist heap balloon growth rather than letting the high-water climb toward machine RAM. Overrides thedaemon.memory_limitconfig value; an explicitGOMEMLIMIToverrides both (the runtime already honors it). Unset applies the default policy: a quarter of host RAM, clamped to[1GiB, 8GiB]. The cold-index window temporarily raises this to a larger budget and restores it afterward.GORTEX_DAEMON_MEMRELEASE=0— disable the post-burst heap-to-OS release. By default the daemon callsdebug.FreeOSMemory()at allocation-burst boundaries (warmup completion, a reconcile-janitor tick that reindexed something, the close of a cold-index window, and a whole-graph analysis pass) so a burst's high-water footprint is returned to the OS promptly instead of pinning resident memory at the peak. It only ever fires at those boundaries, never on a timer.
CLI
gortex track /path/to/repo # Add a repo to the workspace
gortex untrack /path/to/repo # Remove a repo from the workspace
gortex mcp --track /path/to/repo # Track additional repos on startup
gortex mcp --project my-saas # Set active project scope
gortex status # Per-repo and per-project stats
gortex repos # List tracked repos — head-commit SHA, last-indexed time, staleness flag
gortex repos --json # Same, machine-readable (for scripts / CI)
# Stamp workspace / project slugs across tracked repos (migration helper)
gortex workspace list # Show what each tracked repo currently declares
gortex workspace list --json # Same, machine-readable
gortex workspace set backend api # Write workspace=api to backend's .gortex.yaml
gortex workspace set upstream-lib api --global # OSS-friendly: pin to api in ~/.gortex/config.yaml
gortex workspace set-all api --root ~/projects/work --yes # Bulk: stamp every tracked repo under a prefix
# Manage the effective ignore list used by indexing + watching
gortex config exclude list # Show all layers (builtin, global, repo entry, workspace)
gortex config exclude add pkg/generated # Default target: workspace .gortex.yaml
gortex config exclude add '**/*.bak' --global # Write to ~/.gortex/config.yaml
gortex config exclude add testdata/ --repo backend # Write to a RepoEntry
gortex config exclude remove pkg/generated # Remove from the same target
MCP tools
Agents can manage repos at runtime without CLI access:
| Tool | Description |
|---|---|
track_repository |
Add a repo, index immediately, persist to config |
untrack_repository |
Remove a repo, evict nodes/edges, persist to config |
set_active_project |
Switch project scope for all subsequent queries |
get_active_project |
Return current project name and repo list |
Locate, reach, and analyze query tools uniformly accept repo, project, workspace, and scope parameters for scoping (plus ref where reference tags apply). All are clamped to the session workspace — the hard isolation boundary. Default breadth now follows tool intent when scope.intent_defaults is enabled (the default); see Tool scoping by intent below.
For analyze, the overrides genuinely narrow its graph-node kinds — dead_code, hotspots, cycles, health_score, todos, stale_code, ownership, coverage_gaps, coverage_summary, impact, bottlenecks, role, k8s_resources, images, kustomize, dbt_models, external_calls, and the like — and, since v1, its edge-walk / graph-algorithm / framework / file-AST-scan kinds too (channel_ops, pubsub, routes, models, pagerank, kcore, edge_audit, tests_as_edges, sast, review, …), which prune their rows / re-tally their counts against the same workspace + repo allow-set. The narrowing also resolves the two kind-specific collisions: kind=cross_repo keeps repo as its boundary filter and kind=cycles keeps scope as a file-path / package prefix (both are stripped from the uniform scope-resolution view). v1 caveat: the remaining long-tail kinds — community detection (clusters, concepts, suggest_boundaries), git/disk-mining (blame, coverage, fixes_history, retrieval_log, temporal_verify), per-id (would_create_cycle, def_use), synthesizers / resolution_outcomes, and sql_rebuild — remain workspace-bound but are not repo-narrowed — passing a narrowing arg on such a kind stamps a scope_note on the response disclosing the no-op.
Tool scoping by intent
Tools are split by intent — each group has a different default scope:
| Intent | Tools | Default scope |
|---|---|---|
| Locate ("where is X defined") | search_symbols, search_text, find_files |
current repo |
| Reach ("who consumes X") | find_usages, get_callers, get_call_chain, contracts |
workspace |
| Analyze | analyze, review, sast |
workspace (graph-node + edge-walk / algorithm / framework / scan kinds narrow to repo/project/scope; community / git-mining / per-id kinds stay workspace-bound — see the caveat above) |
Other query tools (get_symbol, get_file_summary, smart_context, etc.) keep their existing per-tool scope classification; the intent defaults above apply to the locate/reach/analyze groups listed in the table.
scope.intent_defaults config flag
- Controls the intent-based default scoping described above
- Defaults ON (enabled out of the box — this is the new behavior after upgrade)
- Narrow-only invariant: the intent defaults only ever narrow within the session workspace (the hard isolation boundary); they never widen past it, and an explicit
repo/project/workspace/scopearg always overrides the default - Opt out: set
scope.intent_defaults: falsein.gortex.yaml, or set env varGORTEX_SCOPE_INTENT_DEFAULTS=0
⚠ Upgrade note (behavior change): When upgrading to this version:
- Locate tools narrow their default: project → repo (you now need
repo:"*"to search the whole workspace) - Reach tools widen their default: project → workspace (cross-repo callers surface automatically)
- Restore the old behavior with
scope.intent_defaults: falseorGORTEX_SCOPE_INTENT_DEFAULTS=0
Widen sentinels
When intent defaults are on, you can still widen or narrow explicitly:
repo:"*"— widen a locate tool back to the whole workspaceproject:<name>— select the middle rung (explicit project scope)scope:<name>— select a named saved scope
Uniform parameter set
Every locate/reach/analyze tool now uniformly accepts repo, project, workspace, and scope parameters. All are clamped to the session workspace (the hard isolation boundary). For analyze this narrows the graph-node, edge-walk, graph-algorithm, framework, and file/AST-scan kinds; the remaining community / git-mining / per-id / synthesizer kinds are workspace-bound but not repo-narrowed in v1 (see the MCP tools caveat above).
Response metadata
Scoped tool responses carry a scope_applied meta field plus a one-line widen hint naming an explicit override that re-broadens the result (e.g. repo:"*" for the whole workspace, or project:<name> / scope:<name> to re-scope to a deliberate rung). analyze additionally stamps a scope_note when a narrowing arg is passed to a kind that does not repo-narrow its rows in v1, so the no-op is self-documenting rather than silent.
How it works
- Qualified node IDs — in multi-repo mode, IDs become
<repo_prefix>/<path>::<Symbol>(e.g.,frontend/src/app.ts::App). Single-repo mode keeps the existing<path>::<Symbol>format. - Cross-repo edges — the resolver links symbols across repo boundaries with same-repo preference. Cross-repo edges carry a
cross_repo: trueflag. - Impact analysis —
explain_change_impact,verify_change, andget_test_targetsfollow cross-repo edges automatically, grouping results by repository. - Shared repos — the same repo can appear in multiple projects with different reference tags. It's indexed once and shared across projects.
- Auto-detection — set
workspace.auto_detect: truein.gortex.yamlto auto-discover Git repos in a parent directory.