Skip to content

Commit 0d2f840

Browse files
vitalNohjcursoragent
authored andcommitted
feat(config): add ignore_worktrees to skip linked git worktrees
Agents that use `git worktree add` to run parallel branches end up with one indexed project per worktree. Each is a near-duplicate of the main checkout, so the cache fills with redundant graphs and project pickers get noisy. Add an opt-in `ignore_worktrees` config key. When enabled, a linked worktree is left alone: - auto-index on connect skips it (logged as autoindex.skip) - the daemon's background auto-index skips it - explicit index_repository refuses with an actionable message naming both ways forward (index_worktree=true, or turn the key off) - the hook-augment "not indexed" guidance stops telling the agent to run index_repository in a directory where it would only be refused Detection is git plumbing, no subprocess: <path>/.git must be a regular file holding a "gitdir:" pointer AND that gitdir must contain a `commondir` file. The commondir check is what separates a linked worktree from a submodule, whose .git is also a gitlink file but whose gitdir has no commondir. The main checkout of a repo that has worktrees is never affected. Defaults to false, so behaviour is unchanged unless the key is set. Signed-off-by: Nohj <vitalnohj@gmail.com>
1 parent e783f73 commit 0d2f840

13 files changed

Lines changed: 597 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`).
155155

156156
To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
157157

158+
Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees).
159+
158160
### Keeping Up to Date
159161

160162
**Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run:
@@ -735,6 +737,7 @@ codebase-memory-mcp config list # show all settings
735737
codebase-memory-mcp config set auto_index true # auto-index on session start
736738
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
737739
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
740+
codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false)
738741
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
739742
codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit
740743
codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit

‎docs/CONFIGURATION.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Current keys:
8787
|---|---|---|
8888
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
8989
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
90+
| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. |
9091
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
9192
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. |
9293
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
@@ -126,6 +127,35 @@ and preserves any previously serving database. See
126127
[Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and
127128
error-response details.
128129
130+
### `ignore_worktrees`
131+
132+
Every indexed project is registered under its own absolute root path, so each
133+
linked worktree becomes a separate permanent index. On machines that create many
134+
short-lived worktrees, the automatic paths (`auto_index`, and the session hook's
135+
"index this project first" guidance) turn every throwaway checkout into another
136+
stored index of what is largely the same repository.
137+
138+
Enable the key to keep those checkouts out of the index:
139+
140+
```bash
141+
codebase-memory-mcp config set ignore_worktrees true
142+
```
143+
144+
With it enabled:
145+
146+
- automatic indexing skips a session whose root is a linked worktree;
147+
- the `hook-augment` context says the worktree is unindexed on purpose instead
148+
of telling the agent to run `index_repository`;
149+
- an explicit `index_repository` call on a linked worktree is refused, and names
150+
both ways forward — pass `index_worktree=true` for that one call, or turn the
151+
key back off.
152+
153+
The main checkout of the same repository is unaffected, as are ordinary clones
154+
and submodules. Detection is git plumbing only: a linked worktree's `.git` is a
155+
file pointing at a gitdir that contains a `commondir` entry.
156+
157+
The default is `false`, so indexing behavior is unchanged unless you opt in.
158+
129159
## 3. UI Settings
130160
131161
The optional built-in graph UI stores its settings in:

‎src/cli/cli.c‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = {
74427442
{CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"},
74437443
{CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"},
74447444
{CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"},
7445+
{CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"},
74457446
{CBM_CONFIG_WATCHER_ENABLED, "true",
74467447
"Run the background watcher thread (auto-reindex); false to disable"},
74477448
{CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"},

‎src/cli/cli.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect,
250250
const char *forced_event);
251251
bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
252252
bool case_insensitive);
253-
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event);
253+
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event,
254+
bool worktree_ignored);
254255
bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz);
255256
bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows);
256257
void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter);
@@ -431,6 +432,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t
431432
#define CBM_CONFIG_AUTO_INDEX "auto_index"
432433
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
433434
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
435+
#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees"
434436
#define CBM_CONFIG_UI_LANG "ui-lang"
435437
#define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled"
436438
/* #1558: the graph UI's loopback listener. Stored in the UI config file rather

‎src/cli/hook_augment.c‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
#include "foundation/constants.h"
2222
#include "foundation/mem.h"
2323
#include "foundation/platform.h"
24+
#include "git/git_context.h"
2425
#include "mcp/mcp.h"
26+
#include "mcp/mcp_internal.h"
2527
#include "pipeline/pipeline.h"
2628
#include "yyjson/yyjson.h"
2729

@@ -1440,7 +1442,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) {
14401442
return "Tier 2 verification";
14411443
}
14421444

1443-
static const char *ha_no_project_index_guidance(const char *event) {
1445+
static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) {
1446+
/* ignore_worktrees is on and this cwd is a linked worktree: indexing it is
1447+
* deliberately disabled, so telling the agent to run index_repository would
1448+
* only produce a refusal. Point at the setting instead. */
1449+
if (worktree_ignored) {
1450+
return "This is a linked git worktree and ignore_worktrees is enabled, so it is not "
1451+
"indexed on purpose; do not run index_repository here.";
1452+
}
14441453
return event && strcmp(event, "SubagentStart") == 0
14451454
? "Ask the parent agent to run index_repository before structural exploration; "
14461455
"do not attempt graph mutation."
@@ -1472,6 +1481,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
14721481
}
14731482
const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer));
14741483
char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL;
1484+
bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) &&
1485+
cbm_git_is_linked_worktree(cwd);
14751486
cbm_mcp_server_free(owned_server);
14761487

14771488
char context[2048];
@@ -1505,7 +1516,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
15051516
"and file reads for literals, configs, non-code files, and verification.",
15061517
scope, safe_project, tier);
15071518
} else {
1508-
const char *index_guidance = ha_no_project_index_guidance(event);
1519+
const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored);
15091520
snprintf(context, sizeof(context),
15101521
"[codebase-memory] %s context: no indexed graph project matched this working "
15111522
"directory. %s Once indexed, "
@@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
16241635
return ha_path_contains_mode(root, candidate, case_insensitive);
16251636
}
16261637

1627-
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) {
1628-
return ha_no_project_index_guidance(event);
1638+
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event,
1639+
bool worktree_ignored) {
1640+
return ha_no_project_index_guidance(event, worktree_ignored);
16291641
}
16301642

16311643
bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) {

‎src/daemon/application.c‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include "foundation/sha256.h"
1616
#include "foundation/subprocess.h"
1717
#include "foundation/workspace.h"
18+
#include "git/git_context.h"
1819
#include "mcp/index_supervisor.h"
1920
#include "mcp/mcp.h"
2021
#include "mcp/mcp_internal.h"
@@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio
20712072
!application_session_workspace_allowed(session, "auto_index_discovery")) {
20722073
auto_index_candidate = false;
20732074
}
2075+
if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) &&
2076+
cbm_git_is_linked_worktree(root_path)) {
2077+
cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree");
2078+
auto_index_candidate = false;
2079+
}
20742080
bool within_auto_index_limit =
20752081
!auto_index_candidate ||
20762082
cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files);

‎src/git/git_context.c‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) {
100100
#endif
101101
}
102102

103+
/* Read the "gitdir: <path>" pointer out of a gitlink FILE at <path>/.git.
104+
* Returns false when .git is missing, a directory (ordinary repo), or holds no
105+
* pointer. A relative pointer is resolved against path. */
106+
static bool read_gitlink_target(const char *path, char *out, size_t out_size) {
107+
char dot_git[GIT_OUTPUT_MAX];
108+
int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path);
109+
if (n < 0 || n >= (int)sizeof(dot_git)) {
110+
return false;
111+
}
112+
struct stat st;
113+
if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) {
114+
return false;
115+
}
116+
117+
FILE *f = cbm_fopen(dot_git, "r");
118+
if (!f) {
119+
return false;
120+
}
121+
char line[GIT_OUTPUT_MAX];
122+
bool got = false;
123+
while (fgets(line, sizeof(line), f)) {
124+
trim_newlines(line);
125+
if (strncmp(line, "gitdir:", 7) != 0) {
126+
continue;
127+
}
128+
const char *value = line + 7;
129+
while (*value == ' ' || *value == '\t') {
130+
value++;
131+
}
132+
if (!value[0]) {
133+
break;
134+
}
135+
int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value)
136+
: snprintf(out, out_size, "%s/%s", path, value);
137+
got = written > 0 && written < (int)out_size;
138+
break;
139+
}
140+
fclose(f);
141+
return got;
142+
}
143+
144+
bool cbm_git_is_linked_worktree(const char *path) {
145+
if (!path || !path[0]) {
146+
return false;
147+
}
148+
char git_dir[GIT_OUTPUT_MAX];
149+
if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) {
150+
return false;
151+
}
152+
/* Only linked worktrees carry <gitdir>/commondir; a submodule gitlink
153+
* points at <super>/.git/modules/<name>, which does not. */
154+
char commondir[GIT_OUTPUT_MAX];
155+
int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir);
156+
if (n < 0 || n >= (int)sizeof(commondir)) {
157+
return false;
158+
}
159+
struct stat st;
160+
return stat(commondir, &st) == 0 && S_ISREG(st.st_mode);
161+
}
162+
103163
static char *join_root_relative(const char *root, const char *rel) {
104164
if (!root || !root[0]) {
105165
return git_strdup(rel);

‎src/git/git_context.h‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ typedef struct {
1919
char *base_sha;
2020
} cbm_git_context_t;
2121

22+
/* True when path is the root of a LINKED git worktree (`git worktree add`).
23+
*
24+
* Plumbing-only, no subprocess: <path>/.git must be a regular file holding a
25+
* "gitdir: <path>" pointer AND that gitdir must contain a `commondir` file.
26+
* The commondir check is what separates a linked worktree from a submodule —
27+
* a submodule's .git is also a gitlink file, but its gitdir
28+
* (<super>/.git/modules/<name>) has no commondir entry.
29+
*
30+
* Callers run this on every session start, so it stays fork-free; the richer
31+
* cbm_git_context_resolve() shells out to git and is not usable on that path. */
32+
bool cbm_git_is_linked_worktree(const char *path);
33+
2234
int cbm_git_context_resolve(const char *path, cbm_git_context_t *out);
2335
void cbm_git_context_free(cbm_git_context_t *ctx);
2436
char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx);

‎src/mcp/mcp.c‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = {
478478
"\"name\":{\"type\":\"string\",\"description\":"
479479
"\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"},"
480480
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
481-
"\"Write .codebase-memory/graph.db.zst.\"}"
481+
"\"Write .codebase-memory/graph.db.zst.\"},"
482+
"\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":"
483+
"\"Index repo_path even when it is a linked git worktree and the "
484+
"ignore_worktrees config key is enabled. No effect otherwise.\"}"
482485
"},\"required\":[\"repo_path\"]}"},
483486

484487
{"search_graph",
@@ -11145,6 +11148,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
1114511148
return cbm_mcp_text_result(boundary_err, true);
1114611149
}
1114711150

11151+
/* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree
11152+
* is refused with the two ways forward (per-call override, or turn the
11153+
* setting off) rather than silently skipped — a silent success would be
11154+
* indistinguishable from a real index to the caller. */
11155+
if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") &&
11156+
cbm_git_is_linked_worktree(repo_path)) {
11157+
index_args_free(repo_path, mode_str, name_override);
11158+
return cbm_mcp_text_result(
11159+
"repo_path is a linked git worktree and ignore_worktrees is enabled. Pass "
11160+
"index_worktree=true to index it anyway, or run: codebase-memory-mcp config set "
11161+
"ignore_worktrees false",
11162+
true);
11163+
}
11164+
1114811165
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
1114911166
char *result = handle_cross_repo_mode(srv, repo_path, name_override, args);
1115011167
index_args_free(repo_path, mode_str, name_override);
@@ -17569,6 +17586,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) {
1756917586
return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true);
1757017587
}
1757117588

17589+
/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees
17590+
* (default off, so existing setups keep indexing them). Users who create many
17591+
* short-lived worktrees can stop each throwaway checkout from registering a
17592+
* new permanent project with `config set ignore_worktrees true`. */
17593+
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) {
17594+
if (!srv || !srv->config) {
17595+
return false; /* default off */
17596+
}
17597+
return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false);
17598+
}
17599+
1757217600
/* Register the session project with the background watcher for ongoing
1757317601
* change detection — unless auto_watch is disabled. */
1757417602
static void register_watcher_if_enabled(cbm_mcp_server_t *srv) {
@@ -17721,6 +17749,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
1772117749
return;
1772217750
}
1772317751

17752+
if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) {
17753+
cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project",
17754+
srv->session_project);
17755+
return;
17756+
}
17757+
1772417758
/* Quick tracked-file count check to avoid OOM on massive repos. */
1772517759
int file_count = -1;
1772617760
#ifdef CBM_ENABLE_TEST_SEAMS

‎src/mcp/mcp_internal.h‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy);
6363
bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit,
6464
int *file_count_out);
6565

66+
/* True when the `ignore_worktrees` config key is on for this server (default
67+
* off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an
68+
* automatic index of a linked worktree should be skipped. */
69+
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv);
70+
6671
/* detect_changes seed scoping (#1363): does `node`'s line range overlap any
6772
* recorded hunk for `file`? Exposed for direct unit testing of the overlap
6873
* logic, independent of the git/subprocess/index plumbing around it. */

0 commit comments

Comments
 (0)