Skip to content

Commit 4e220a2

Browse files
committed
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 0d6f26a commit 4e220a2

12 files changed

Lines changed: 398 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ When enabled, new projects are indexed automatically on first connection. Previo
153153

154154
Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing.
155155

156+
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).
157+
156158
### Keeping Up to Date
157159

158160
**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:
@@ -663,6 +665,7 @@ codebase-memory-mcp config list # show all settings
663665
codebase-memory-mcp config set auto_index true # auto-index on session start
664666
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
665667
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
668+
codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false)
666669
codebase-memory-mcp config reset auto_index # reset to default
667670
```
668671

‎docs/CONFIGURATION.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,36 @@ Current keys:
8686
|---|---|---|
8787
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
8888
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
89+
| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. |
90+
91+
### `ignore_worktrees`
92+
93+
Every indexed project is registered under its own absolute root path, so each
94+
linked worktree becomes a separate permanent index. On machines that create many
95+
short-lived worktrees, the automatic paths (`auto_index`, and the session hook's
96+
"index this project first" guidance) turn every throwaway checkout into another
97+
stored index of what is largely the same repository.
98+
99+
Enable the key to keep those checkouts out of the index:
100+
101+
```bash
102+
codebase-memory-mcp config set ignore_worktrees true
103+
```
104+
105+
With it enabled:
106+
107+
- automatic indexing skips a session whose root is a linked worktree;
108+
- the `hook-augment` context says the worktree is unindexed on purpose instead
109+
of telling the agent to run `index_repository`;
110+
- an explicit `index_repository` call on a linked worktree is refused, and names
111+
both ways forward — pass `index_worktree=true` for that one call, or turn the
112+
key back off.
113+
114+
The main checkout of the same repository is unaffected, as are ordinary clones
115+
and submodules. Detection is git plumbing only: a linked worktree's `.git` is a
116+
file pointing at a gitdir that contains a `commondir` entry.
117+
118+
The default is `false`, so indexing behavior is unchanged unless you opt in.
89119

90120
## 3. UI Settings
91121

‎src/cli/cli.c‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6481,6 +6481,8 @@ int cbm_cmd_config(int argc, char **argv) {
64816481
"Max files for auto-indexing new projects");
64826482
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_WATCH, "true",
64836483
"Register background git watcher on session connect");
6484+
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_IGNORE_WORKTREES, "false",
6485+
"Skip linked git worktrees when indexing automatically");
64846486
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
64856487
"Pin graph UI language: en, zh, or auto");
64866488
return 0;
@@ -6510,6 +6512,8 @@ int cbm_cmd_config(int argc, char **argv) {
65106512
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
65116513
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_WATCH,
65126514
cbm_config_get(cfg, CBM_CONFIG_AUTO_WATCH, "true"));
6515+
printf(" %-25s = %-10s\n", CBM_CONFIG_IGNORE_WORKTREES,
6516+
cbm_config_get(cfg, CBM_CONFIG_IGNORE_WORKTREES, "false"));
65136517
printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
65146518
cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
65156519
} else if (strcmp(argv[0], "get") == 0) {

‎src/cli/cli.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
394394
#define CBM_CONFIG_AUTO_INDEX "auto_index"
395395
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
396396
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
397+
#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees"
397398
#define CBM_CONFIG_UI_LANG "ui-lang"
398399

399400
/* ── Binary activation safety ─────────────────────────────────── */

‎src/cli/hook_augment.c‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
#include "foundation/compat_fs.h"
2121
#include "foundation/constants.h"
2222
#include "foundation/mem.h"
23+
#include "git/git_context.h"
2324
#include "mcp/mcp.h"
25+
#include "mcp/mcp_internal.h"
2426
#include "pipeline/pipeline.h"
2527
#include "yyjson/yyjson.h"
2628

@@ -1123,7 +1125,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) {
11231125
return "Tier 2 verification";
11241126
}
11251127

1126-
static const char *ha_no_project_index_guidance(const char *event) {
1128+
static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) {
1129+
/* ignore_worktrees is on and this cwd is a linked worktree: indexing it is
1130+
* deliberately disabled, so telling the agent to run index_repository would
1131+
* only produce a refusal. Point at the setting instead. */
1132+
if (worktree_ignored) {
1133+
return "This is a linked git worktree and ignore_worktrees is enabled, so it is not "
1134+
"indexed on purpose; do not run index_repository here.";
1135+
}
11271136
return event && strcmp(event, "SubagentStart") == 0
11281137
? "Ask the parent agent to run index_repository before structural exploration; "
11291138
"do not attempt graph mutation."
@@ -1155,6 +1164,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
11551164
}
11561165
const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer));
11571166
char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL;
1167+
bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) &&
1168+
cbm_git_is_linked_worktree(cwd);
11581169
cbm_mcp_server_free(owned_server);
11591170

11601171
char context[2048];
@@ -1188,7 +1199,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
11881199
"and file reads for literals, configs, non-code files, and verification.",
11891200
scope, safe_project, tier);
11901201
} else {
1191-
const char *index_guidance = ha_no_project_index_guidance(event);
1202+
const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored);
11921203
snprintf(context, sizeof(context),
11931204
"[codebase-memory] %s context: no indexed graph project matched this working "
11941205
"directory. %s Once indexed, "
@@ -1308,7 +1319,7 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
13081319
}
13091320

13101321
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) {
1311-
return ha_no_project_index_guidance(event);
1322+
return ha_no_project_index_guidance(event, false);
13121323
}
13131324
#endif
13141325

‎src/daemon/application.c‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "foundation/mem.h"
1313
#include "foundation/platform.h"
1414
#include "foundation/subprocess.h"
15+
#include "git/git_context.h"
1516
#include "mcp/index_supervisor.h"
1617
#include "mcp/mcp.h"
1718
#include "mcp/mcp_internal.h"
@@ -1935,6 +1936,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio
19351936
: CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT;
19361937
int tracked_files = -1;
19371938
bool auto_index_candidate = auto_index && !db_exists;
1939+
if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) &&
1940+
cbm_git_is_linked_worktree(root_path)) {
1941+
cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree");
1942+
auto_index_candidate = false;
1943+
}
19381944
bool within_auto_index_limit =
19391945
!auto_index_candidate ||
19401946
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: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,10 @@ static const tool_def_t TOOLS[] = {
384384
"are normalized.\"},"
385385
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
386386
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
387-
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
387+
"Teammates can bootstrap from the artifact instead of full re-indexing.\"},"
388+
"\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":"
389+
"\"Index repo_path even when it is a linked git worktree and the "
390+
"ignore_worktrees config key is enabled. No effect otherwise.\"}"
388391
"},\"required\":[\"repo_path\"]}"},
389392

390393
{"search_graph", "Search graph",
@@ -7916,6 +7919,22 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
79167919
return cbm_mcp_text_result(boundary_err, true);
79177920
}
79187921

7922+
/* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree
7923+
* is refused with the two ways forward (per-call override, or turn the
7924+
* setting off) rather than silently skipped — a silent success would be
7925+
* indistinguishable from a real index to the caller. */
7926+
if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") &&
7927+
cbm_git_is_linked_worktree(repo_path)) {
7928+
free(mode_str);
7929+
free(name_override);
7930+
free(repo_path);
7931+
return cbm_mcp_text_result(
7932+
"repo_path is a linked git worktree and ignore_worktrees is enabled. Pass "
7933+
"index_worktree=true to index it anyway, or run: codebase-memory-mcp config set "
7934+
"ignore_worktrees false",
7935+
true);
7936+
}
7937+
79197938
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
79207939
free(mode_str);
79217940
char *result = handle_cross_repo_mode(srv, repo_path, name_override, args);
@@ -11094,6 +11113,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) {
1109411113
return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true);
1109511114
}
1109611115

11116+
/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees
11117+
* (default off, so existing setups keep indexing them). Users who create many
11118+
* short-lived worktrees can stop each throwaway checkout from registering a
11119+
* new permanent project with `config set ignore_worktrees true`. */
11120+
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) {
11121+
if (!srv || !srv->config) {
11122+
return false; /* default off */
11123+
}
11124+
return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false);
11125+
}
11126+
1109711127
/* Register the session project with the background watcher for ongoing
1109811128
* change detection — unless auto_watch is disabled. */
1109911129
static void register_watcher_if_enabled(cbm_mcp_server_t *srv) {
@@ -11220,6 +11250,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
1122011250
return;
1122111251
}
1122211252

11253+
if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) {
11254+
cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project",
11255+
srv->session_project);
11256+
return;
11257+
}
11258+
1122311259
/* Quick tracked-file count check to avoid OOM on massive repos. */
1122411260
int file_count = -1;
1122511261
if (!cbm_mcp_auto_index_within_file_limit(srv->session_root, file_limit, &file_count)) {

‎src/mcp/mcp_internal.h‎

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

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

0 commit comments

Comments
 (0)