From 4e220a2e313b1c1bf35714c82f1e51177f808e32 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 1/2] 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: /.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 --- README.md | 3 ++ docs/CONFIGURATION.md | 30 +++++++++++ src/cli/cli.c | 4 ++ src/cli/cli.h | 1 + src/cli/hook_augment.c | 17 ++++-- src/daemon/application.c | 6 +++ src/git/git_context.c | 60 +++++++++++++++++++++ src/git/git_context.h | 12 +++++ src/mcp/mcp.c | 38 ++++++++++++- src/mcp/mcp_internal.h | 5 ++ tests/test_git_context.c | 114 +++++++++++++++++++++++++++++++++++++++ tests/test_mcp.c | 112 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 398 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 926d13d7b..d15fefd07 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ When enabled, new projects are indexed automatically on first connection. Previo 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. +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). + ### Keeping Up to Date **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 codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config reset auto_index # reset to default ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e0a589536..5fa40fa97 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -86,6 +86,36 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. | + +### `ignore_worktrees` + +Every indexed project is registered under its own absolute root path, so each +linked worktree becomes a separate permanent index. On machines that create many +short-lived worktrees, the automatic paths (`auto_index`, and the session hook's +"index this project first" guidance) turn every throwaway checkout into another +stored index of what is largely the same repository. + +Enable the key to keep those checkouts out of the index: + +```bash +codebase-memory-mcp config set ignore_worktrees true +``` + +With it enabled: + +- automatic indexing skips a session whose root is a linked worktree; +- the `hook-augment` context says the worktree is unindexed on purpose instead + of telling the agent to run `index_repository`; +- an explicit `index_repository` call on a linked worktree is refused, and names + both ways forward — pass `index_worktree=true` for that one call, or turn the + key back off. + +The main checkout of the same repository is unaffected, as are ordinary clones +and submodules. Detection is git plumbing only: a linked worktree's `.git` is a +file pointing at a gitdir that contains a `commondir` entry. + +The default is `false`, so indexing behavior is unchanged unless you opt in. ## 3. UI Settings diff --git a/src/cli/cli.c b/src/cli/cli.c index c58738a42..c6716f64e 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -6481,6 +6481,8 @@ int cbm_cmd_config(int argc, char **argv) { "Max files for auto-indexing new projects"); printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"); + printf(" %-25s default=%-10s %s\n", CBM_CONFIG_IGNORE_WORKTREES, "false", + "Skip linked git worktrees when indexing automatically"); printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"); return 0; @@ -6510,6 +6512,8 @@ int cbm_cmd_config(int argc, char **argv) { cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000")); printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_WATCH, cbm_config_get(cfg, CBM_CONFIG_AUTO_WATCH, "true")); + printf(" %-25s = %-10s\n", CBM_CONFIG_IGNORE_WORKTREES, + cbm_config_get(cfg, CBM_CONFIG_IGNORE_WORKTREES, "false")); printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG, cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto")); } else if (strcmp(argv[0], "get") == 0) { diff --git a/src/cli/cli.h b/src/cli/cli.h index de4939c74..5e40e2f9f 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -394,6 +394,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key); #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_UI_LANG "ui-lang" /* ── Binary activation safety ─────────────────────────────────── */ diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 73ad1c45b..e56f762c4 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -20,7 +20,9 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/mem.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1123,7 +1125,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) { return "Tier 2 verification"; } -static const char *ha_no_project_index_guidance(const char *event) { +static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) { + /* ignore_worktrees is on and this cwd is a linked worktree: indexing it is + * deliberately disabled, so telling the agent to run index_repository would + * only produce a refusal. Point at the setting instead. */ + if (worktree_ignored) { + return "This is a linked git worktree and ignore_worktrees is enabled, so it is not " + "indexed on purpose; do not run index_repository here."; + } return event && strcmp(event, "SubagentStart") == 0 ? "Ask the parent agent to run index_repository before structural exploration; " "do not attempt graph mutation." @@ -1155,6 +1164,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); cbm_mcp_server_free(owned_server); char context[2048]; @@ -1188,7 +1199,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "and file reads for literals, configs, non-code files, and verification.", scope, safe_project, tier); } else { - const char *index_guidance = ha_no_project_index_guidance(event); + const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored); snprintf(context, sizeof(context), "[codebase-memory] %s context: no indexed graph project matched this working " "directory. %s Once indexed, " @@ -1308,7 +1319,7 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, } const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); + return ha_no_project_index_guidance(event, false); } #endif diff --git a/src/daemon/application.c b/src/daemon/application.c index f8903e4d9..e9f773472 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -12,6 +12,7 @@ #include "foundation/mem.h" #include "foundation/platform.h" #include "foundation/subprocess.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -1935,6 +1936,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio : CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT; int tracked_files = -1; bool auto_index_candidate = auto_index && !db_exists; + if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree"); + auto_index_candidate = false; + } bool within_auto_index_limit = !auto_index_candidate || cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6..2734dc703 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) { #endif } +/* Read the "gitdir: " pointer out of a gitlink FILE at /.git. + * Returns false when .git is missing, a directory (ordinary repo), or holds no + * pointer. A relative pointer is resolved against path. */ +static bool read_gitlink_target(const char *path, char *out, size_t out_size) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return false; + } + struct stat st; + if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + FILE *f = cbm_fopen(dot_git, "r"); + if (!f) { + return false; + } + char line[GIT_OUTPUT_MAX]; + bool got = false; + while (fgets(line, sizeof(line), f)) { + trim_newlines(line); + if (strncmp(line, "gitdir:", 7) != 0) { + continue; + } + const char *value = line + 7; + while (*value == ' ' || *value == '\t') { + value++; + } + if (!value[0]) { + break; + } + int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value) + : snprintf(out, out_size, "%s/%s", path, value); + got = written > 0 && written < (int)out_size; + break; + } + fclose(f); + return got; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char git_dir[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { + return false; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + if (n < 0 || n >= (int)sizeof(commondir)) { + return false; + } + struct stat st; + return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..633e28018 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,6 +19,18 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is the root of a LINKED git worktree (`git worktree add`). + * + * Plumbing-only, no subprocess: /.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 — + * a submodule's .git is also a gitlink file, but its gitdir + * (/.git/modules/) has no commondir entry. + * + * Callers run this on every session start, so it stays fork-free; the richer + * cbm_git_context_resolve() shells out to git and is not usable on that path. */ +bool cbm_git_is_linked_worktree(const char *path); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 63dfa75c6..0d92b778f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -384,7 +384,10 @@ static const tool_def_t TOOLS[] = { "are normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. " - "Teammates can bootstrap from the artifact instead of full re-indexing.\"}" + "Teammates can bootstrap from the artifact instead of full re-indexing.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the " + "ignore_worktrees config key is enabled. No effect otherwise.\"}" "},\"required\":[\"repo_path\"]}"}, {"search_graph", "Search graph", @@ -7916,6 +7919,22 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(boundary_err, true); } + /* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree + * is refused with the two ways forward (per-call override, or turn the + * setting off) rather than silently skipped — a silent success would be + * indistinguishable from a real index to the caller. */ + if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") && + cbm_git_is_linked_worktree(repo_path)) { + free(mode_str); + free(name_override); + free(repo_path); + return cbm_mcp_text_result( + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "index_worktree=true to index it anyway, or run: codebase-memory-mcp config set " + "ignore_worktrees false", + true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { free(mode_str); 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) { return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true); } +/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees + * (default off, so existing setups keep indexing them). Users who create many + * short-lived worktrees can stop each throwaway checkout from registering a + * new permanent project with `config set ignore_worktrees true`. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { + if (!srv || !srv->config) { + return false; /* default off */ + } + return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ 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) { return; } + if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) { + cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project", + srv->session_project); + return; + } + /* Quick tracked-file count check to avoid OOM on massive repos. */ int file_count = -1; if (!cbm_mcp_auto_index_within_file_limit(srv->session_root, file_limit, &file_count)) { diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 313a4d1ce..acfbd8ce0 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -43,6 +43,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy); bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, int *file_count_out); +/* True when the `ignore_worktrees` config key is on for this server (default + * off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an + * automatic index of a linked worktree should be skipped. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..08df0dd7b 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -232,10 +232,124 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */ +/* Detection backing the `ignore_worktrees` config gate. The predicate must be + * TRUE only for a linked worktree (`git worktree add`) — never for the main + * checkout, a plain directory, or a submodule. The submodule case is the one a + * naive "is .git a regular file?" check gets wrong: a submodule's .git is also a + * gitlink file, and it is separated here by the absence of a `commondir` entry + * in the pointed-at gitdir. */ + +TEST(is_linked_worktree_true_for_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_wt_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_wt_linked"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + th_rmtree(wt_tmp); /* git worktree add creates it */ + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + + bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* The MAIN checkout of the very same repo must NOT be flagged — otherwise + * enabling ignore_worktrees would stop indexing ordinary repositories. */ + bool main_detected = cbm_git_is_linked_worktree(main_tmp); + + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(!main_detected); + PASS(); +#endif /* _WIN32 */ +} + +TEST(is_linked_worktree_false_for_submodule_and_nongit) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + char super_tmp[256]; + char *raw = th_mktempdir("cbm_wt_super"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(super_tmp, raw, sizeof(super_tmp) - 1); + super_tmp[sizeof(super_tmp) - 1] = '\0'; + + char child_tmp[256]; + raw = th_mktempdir("cbm_wt_child"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(child_tmp, raw, sizeof(child_tmp) - 1); + child_tmp[sizeof(child_tmp) - 1] = '\0'; + + if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A plain directory that is not a git repo at all. */ + char plain[1024]; + snprintf(plain, sizeof(plain), "%s/plain", super_tmp); + if (th_mkdir_p(plain) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + FAIL("failed to create plain dir"); + } + bool plain_detected = cbm_git_is_linked_worktree(plain); + + /* file:// submodules are refused by default since the CVE-2022-39253 fix. */ + char sub_cmd[1024]; + snprintf(sub_cmd, sizeof(sub_cmd), + "-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp); + int sub_rc = git_run(super_tmp, sub_cmd); + + char subm[1024]; + snprintf(subm, sizeof(subm), "%s/subm", super_tmp); + bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + + th_rmtree(super_tmp); + th_rmtree(child_tmp); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + ASSERT(!submodule_detected); + PASS(); +#endif /* _WIN32 */ +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(is_linked_worktree_true_for_linked_worktree); + RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ebe5732f2..227873fc0 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -9637,6 +9637,116 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) { PASS(); } +/* ═══════════════════════════════════════════════════════════════ + * ignore_worktrees — explicit index_repository on a linked worktree + * ═══════════════════════════════════════════════════════════════ */ + +/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on + * AND repo_path is a linked worktree AND no per-call override was passed. The + * refusal must never fire for the MAIN checkout — that would break ordinary + * indexing for anyone enabling the key — and index_worktree=true must escape it. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) { + char args[2048]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path, + override ? ",\"index_worktree\":true" : ""); + char *result = cbm_mcp_handle_tool(srv, "index_repository", args); + bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL; + free(result); + return refused; +} + +static int ignore_worktrees_index_probe(void) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-ignorewt-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + return -1; + } + + char main_repo[512]; + char wt_repo[512]; + snprintf(main_repo, sizeof(main_repo), "%s/main", cache); + snprintf(wt_repo, sizeof(wt_repo), "%s/wt", cache); + if (th_mkdir_p(main_repo) != 0) { + th_rmtree(cache); + return -2; + } + + /* Minimal repo + one linked worktree. Any git failure => skip, not fail. */ + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q >/dev/null 2>&1 && " + "git -C \"%s\" config user.email t@example.com && " + "git -C \"%s\" config user.name T && touch \"%s/.keep\" && " + "git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && " + "git -C \"%s\" worktree add -q \"%s\" -b wtb", + main_repo, main_repo, main_repo, main_repo, main_repo, main_repo, main_repo, wt_repo); + if (system(cmd) != 0) { + th_rmtree(cache); + return -3; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(cache); + if (cfg) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true"); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_config(srv, cfg); + bits = 0; + if (ignore_wt_refused(srv, wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, wt_repo, true)) { + bits |= IGNORE_WT_REFUSED_OVERRIDE; + } + cbm_mcp_server_free(srv); + } + cbm_config_close(cfg); + } + + restore_cache_dir(saved_copy); + free(saved_copy); + + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", main_repo); + (void)system(prune); + th_rmtree(cache); + return bits; +} +#endif /* !_WIN32 */ + +TEST(mcp_ignore_worktrees_gates_explicit_index_repository) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(); + if (bits < 0) { + PASS(); /* git/tmpdir fixture unavailable — skip */ + } + /* RED before the gate existed: nothing is ever refused. */ + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + /* ══════════════════════════════════════════════════════════════════ * #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index * watcher registration (keystone × #849 merge interaction) @@ -10553,6 +10663,8 @@ SUITE(mcp) { RUN_TEST(mcp_auto_watch_default_registers_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); } /* Kept separate so daemon-coordination regressions can be iterated without From d21918c7dff9c53bf68274516069d7f2f6b0ad1f Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 08:45:08 -0400 Subject: [PATCH 2/2] ci: retrigger checks The macos-14 TSan leg failed in test_mcp.c's #853 auto_watch guard, which runs a real indexing worker in a forked child under a wall-clock alarm(60). That budget is timing-sensitive under TSan instrumentation on the macOS runners and is unrelated to this change: the ignore_worktrees gate short-circuits on the config key, so with the key unset (the default, and what that test uses) the worktree check is never evaluated. Signed-off-by: Nohj