From 0d2f840c8ed2695db206acbcad1ad1c36a1f3958 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 01/17] 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 | 1 + src/cli/cli.h | 4 +- src/cli/hook_augment.c | 20 ++- src/daemon/application.c | 6 + src/git/git_context.c | 60 ++++++++ src/git/git_context.h | 12 ++ src/mcp/mcp.c | 36 ++++- src/mcp/mcp_internal.h | 5 + tests/test_cli.c | 22 ++- tests/test_git_context.c | 114 +++++++++++++++ tests/test_mcp.c | 292 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 597 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 66b50fe082..3a9b2137dc 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). 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). +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: @@ -735,6 +737,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 set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 038256976e..4c27e7f574 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ 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. | | `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). | | `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. | | `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 [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `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 The optional built-in graph UI stores its settings in: diff --git a/src/cli/cli.c b/src/cli/cli.c index b59a894a6a..13c6789436 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index cf7f6fa443..ada71ab01a 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -250,7 +250,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); 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 #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" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c3654..1043b48cda 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -21,7 +21,9 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1440,7 +1442,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." @@ -1472,6 +1481,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]; @@ -1505,7 +1516,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, " @@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb757..8a79ecd5dd 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -15,6 +15,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + 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 f739c46e6a..2734dc703b 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 876309eb6a..633e280189 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 75ff8e396c..f27ddb9a8b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = { "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" - "\"Write .codebase-memory/graph.db.zst.\"}" + "\"Write .codebase-memory/graph.db.zst.\"}," + "\"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", @@ -11145,6 +11148,20 @@ 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)) { + index_args_free(repo_path, mode_str, name_override); + 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) { char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); index_args_free(repo_path, mode_str, name_override); @@ -17569,6 +17586,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) { @@ -17721,6 +17749,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; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index de170067a8..8e78f9f406 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -63,6 +63,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_cli.c b/tests/test_cli.c index 211ec63555..46f826d639 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12263,14 +12263,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -15578,6 +15582,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -16011,6 +16028,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a50..08df0dd7b1 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 ffa9ea3a3b..787b26e2e2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -19278,6 +19278,291 @@ 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. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + 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", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +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(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + 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, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.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); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#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("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* 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 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -20766,6 +21051,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif } /* Kept separate so daemon-coordination regressions can be iterated without From 16698e8193fa1dbb53f34f7205569495b3b5fbca Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 02/17] 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 | 1 + src/cli/cli.h | 4 +- src/cli/hook_augment.c | 20 ++- src/daemon/application.c | 6 + src/git/git_context.c | 60 ++++++++ src/git/git_context.h | 12 ++ src/mcp/mcp.c | 36 ++++- src/mcp/mcp_internal.h | 5 + tests/test_cli.c | 22 ++- tests/test_git_context.c | 114 +++++++++++++++ tests/test_mcp.c | 292 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 597 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9c82c18aed..fb4ce7326e 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). 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). +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: @@ -735,6 +737,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 set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 038256976e..4c27e7f574 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ 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. | | `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). | | `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. | | `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 [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `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 The optional built-in graph UI stores its settings in: diff --git a/src/cli/cli.c b/src/cli/cli.c index 6e33759e4a..08aded6219 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index 5522edef06..a6ef66618c 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -262,7 +262,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); @@ -443,6 +444,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #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" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c3654..1043b48cda 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -21,7 +21,9 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1440,7 +1442,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." @@ -1472,6 +1481,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]; @@ -1505,7 +1516,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, " @@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb757..8a79ecd5dd 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -15,6 +15,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + 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 47956562f4..cb27c17cf4 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 876309eb6a..633e280189 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 78b8dccf96..41a63273eb 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = { "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" - "\"Write .codebase-memory/graph.db.zst.\"}" + "\"Write .codebase-memory/graph.db.zst.\"}," + "\"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", @@ -11174,6 +11177,20 @@ 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)) { + index_args_free(repo_path, mode_str, name_override); + 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) { char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); index_args_free(repo_path, mode_str, name_override); @@ -17613,6 +17630,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) { @@ -17765,6 +17793,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; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index de170067a8..8e78f9f406 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -63,6 +63,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_cli.c b/tests/test_cli.c index 7cd44244e3..14553ef680 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12536,14 +12536,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -15941,6 +15945,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -16374,6 +16391,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index cc94f1d06e..894cb68aac 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -253,6 +253,118 @@ 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) { @@ -260,4 +372,6 @@ 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 60649f5054..b7b1335a12 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -19335,6 +19335,291 @@ 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. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + 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", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +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(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + 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, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.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); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#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("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* 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 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -20824,6 +21109,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif } /* Kept separate so daemon-coordination regressions can be iterated without From d63d0e16d18cf46836f0d019a03726bb6a577d16 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 03:23:24 +0000 Subject: [PATCH 03/17] fix(git): probe worktree gitlinks without stat() Upstream's Unicode path fix dropped stat() from this file because it follows symlinks and rejects non-ASCII paths on Windows. Keep ignore_worktrees detection on cbm_path_info_utf8 so the rebase does not restore that probe. Signed-off-by: Nohj --- src/git/git_context.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index cb27c17cf4..3a3da7acb4 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -86,6 +86,14 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return *out ? 0 : CBM_NOT_FOUND; } +/* Regular file, not a directory and not a symlink. Uses the Unicode-safe + * path probe: stat() follows links and rejects non-ASCII paths on Windows, + * which upstream removed from this file in the Unicode repository-path fix. */ +static bool path_is_regular_file(const char *path) { + cbm_path_info_t info; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular; +} + static bool path_is_absolute(const char *path) { if (!path || !path[0]) { return false; @@ -109,8 +117,7 @@ static bool read_gitlink_target(const char *path, char *out, size_t out_size) { if (n < 0 || n >= (int)sizeof(dot_git)) { return false; } - struct stat st; - if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + if (!path_is_regular_file(dot_git)) { return false; } @@ -156,8 +163,7 @@ bool cbm_git_is_linked_worktree(const char *path) { if (n < 0 || n >= (int)sizeof(commondir)) { return false; } - struct stat st; - return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); + return path_is_regular_file(commondir); } static char *join_root_relative(const char *root, const char *rel) { From 3bb85c8c7c3c005fc59bed0a8ee5c467d903a761 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 03:37:57 +0000 Subject: [PATCH 04/17] fix(daemon): recheck ignore_worktrees on auto-index retry A session queued while capacity was full could still be indexed after the setting was turned on. Retry admission now applies the same linked-worktree gate as the initial path. Signed-off-by: Nohj --- src/daemon/application.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/daemon/application.c b/src/daemon/application.c index 8a79ecd5dd..153bc0d81e 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -1457,6 +1457,18 @@ static void application_auto_index_retry_pending_locked(cbm_daemon_application_t application_refresh_watch_locked(session); continue; } + /* ignore_worktrees can be turned on after this session was queued for + * a capacity slot. Initial admission already skips linked worktrees; + * retry must apply the same gate or the pending checkout is indexed + * once a slot opens. */ + if (cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + session->auto_index_retry_pending = false; + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", + "linked_worktree"); + application_refresh_watch_locked(session); + continue; + } char *args = application_auto_index_args(application, root_path); if (!args) { continue; From 5d5c9621eb3e94ee26ab39c26b15237fb38223df Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 10:44:02 +0000 Subject: [PATCH 05/17] fix(cli): load runtime config for the hook-augment worktree note The production hook builds its server with cbm_mcp_server_new(NULL), so ignore_worktrees stayed off and an unindexed linked worktree still told the agent to run index_repository. Attach the runtime store for that check, then drop it before return. Signed-off-by: Nohj --- src/cli/hook_augment.c | 14 ++++++++++++++ src/mcp/mcp.c | 12 ++++++++++++ src/mcp/mcp.h | 6 ++++++ tests/test_cli.c | 16 +++++++++++++++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 1043b48cda..a389e487ff 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1475,14 +1475,28 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root char cwd_buffer[4096]; cbm_mcp_server_t *owned_server = NULL; + cbm_config_t *owned_config = NULL; if (!srv) { owned_server = cbm_mcp_server_new(NULL); srv = owned_server; } + /* cbm_mcp_server_new(NULL) does not attach the runtime store. The production + * hook uses that constructor, so ignore_worktrees would stay at its default + * (off) and the "not indexed" note would still tell the agent to run + * index_repository in a linked worktree the setting refuses. Load the store + * only when the caller has not already set one, then drop it before return + * so a caller-owned server is not left pointing at a closed config. */ + if (srv) { + owned_config = cbm_mcp_server_attach_runtime_config(srv); + } 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); + if (owned_config) { + cbm_mcp_server_set_config(srv, NULL); + cbm_config_close(owned_config); + } cbm_mcp_server_free(owned_server); char context[2048]; diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 41a63273eb..19083feee6 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1751,6 +1751,18 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { + if (!srv || srv->config) { + return NULL; + } + cbm_config_t *cfg = cbm_config_open(cbm_resolve_cache_dir()); + if (!cfg) { + return NULL; + } + srv->config = cfg; + return cfg; +} + #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 09befb7809..6fb110181e 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -158,6 +158,12 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); +/* Open the runtime config and attach it when srv has none. The returned store + * is owned by the caller, who must clear it with cbm_mcp_server_set_config + * before closing. Returns NULL when a config is already set or the store + * cannot be opened. */ +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv); + /* Set an explicit session context for an embedded/daemon-backed server. * session_root is copied and its project name is derived using the same naming * rule as indexing. allowed_root is copied when non-NULL; an explicit NULL diff --git a/tests/test_cli.c b/tests/test_cli.c index 14553ef680..5d7441ec41 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10629,9 +10629,21 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); char *wt_out = cbm_hook_augment_lifecycle_json(input); bool deliberate = wt_out && wt_out[0] && strstr(wt_out, "no indexed graph project matched") && - !strstr(wt_out, "is indexed"); + strstr(wt_out, "Run index_repository") && + !strstr(wt_out, "ignore_worktrees is enabled") && !strstr(wt_out, "is indexed"); free(wt_out); + /* The hook must read the runtime store. With the key off the note still + * says to index; with it on, the same unindexed worktree must not. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool configured = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + char *ignored_out = configured ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool honored = ignored_out && strstr(ignored_out, "ignore_worktrees is enabled") && + strstr(ignored_out, "do not run index_repository") && + !strstr(ignored_out, "is indexed"); + free(ignored_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10647,6 +10659,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { if (!deliberate) FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); + if (!honored) + FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); From 0071007396568b44fb331bb876084dd88a91a415 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 10:59:57 +0000 Subject: [PATCH 06/17] fix(git): detect linked worktrees from nested directories ignore_worktrees only inspected /.git, so a session or index path inside a linked worktree still passed the gate. Walk ancestors until a git anchor, and stop at a main checkout or a submodule. Signed-off-by: Nohj --- src/git/git_context.c | 87 +++++++++++++++++++++++++++++++++++++--- src/git/git_context.h | 13 +++--- tests/test_git_context.c | 23 ++++++++++- 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index 3a3da7acb4..3fbf5b65d6 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -148,22 +148,97 @@ static bool read_gitlink_target(const char *path, char *out, size_t out_size) { return got; } -bool cbm_git_is_linked_worktree(const char *path) { - if (!path || !path[0]) { - return false; +/* 1 = linked-worktree gitlink, 0 = a different git anchor (stop walking), + * -1 = no .git at this directory (keep walking). */ +static int linked_worktree_anchor(const char *path) { + 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 0; + } + cbm_path_info_t info; + if (cbm_path_info_utf8(dot_git, &info) != CBM_PATH_INFO_OK) { + return -1; + } + /* A directory .git is a main checkout. Anything else that is not a + * regular gitlink file (a symlink, for example) is also an anchor: + * do not follow it, and do not blame a parent worktree for this tree. */ + if (!info.is_regular) { + return 0; } char git_dir[GIT_OUTPUT_MAX]; if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { - return false; + return 0; } /* 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); + n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); if (n < 0 || n >= (int)sizeof(commondir)) { + return 0; + } + return path_is_regular_file(commondir) ? 1 : 0; +} + +static bool parent_directory(char *path) { + size_t n = strlen(path); + while (n > 1 && (path[n - 1] == '/' || path[n - 1] == '\\')) { + path[--n] = '\0'; + } +#ifdef _WIN32 + if ((n == 2 && path[1] == ':') || + (n == 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) { return false; } - return path_is_regular_file(commondir); +#endif + if (n <= 1) { + return false; + } + char *slash = NULL; + for (size_t i = 0; i < n; i++) { + if (path[i] == '/' || path[i] == '\\') { + slash = path + i; + } + } + if (!slash) { + return false; + } + if (slash == path) { + path[1] = '\0'; + return true; + } + *slash = '\0'; + return true; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char current[GIT_OUTPUT_MAX]; + int n = snprintf(current, sizeof(current), "%s", path); + if (n < 0 || n >= (int)sizeof(current)) { + return false; + } + size_t len = strlen(current); + while (len > 1 && (current[len - 1] == '/' || current[len - 1] == '\\')) { + current[--len] = '\0'; + } + /* A session cwd or index_repository path is often a subdirectory. Walk + * ancestors until a git anchor so ignore_worktrees applies to the whole + * linked checkout, not only its root. */ + for (int depth = 0; depth < 64; depth++) { + int kind = linked_worktree_anchor(current); + if (kind >= 0) { + return kind == 1; + } + char previous[GIT_OUTPUT_MAX]; + snprintf(previous, sizeof(previous), "%s", current); + if (!parent_directory(current) || strcmp(previous, current) == 0) { + return false; + } + } + return false; } static char *join_root_relative(const char *root, const char *rel) { diff --git a/src/git/git_context.h b/src/git/git_context.h index 633e280189..30f2f545a1 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,13 +19,14 @@ typedef struct { char *base_sha; } cbm_git_context_t; -/* True when path is the root of a LINKED git worktree (`git worktree add`). +/* True when path is a LINKED git worktree (`git worktree add`) or a + * subdirectory of one. * - * 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. + * Plumbing-only, no subprocess. Walk ancestors until a git anchor. A linked + * worktree's /.git is a regular file holding a "gitdir: " pointer + * AND that gitdir contains a `commondir` file. The walk stops at any other + * anchor: a main checkout (`.git` is a directory) or a submodule (gitlink + * file whose gitdir, /.git/modules/, has no commondir). * * 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. */ diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 894cb68aac..5b3c4b7790 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -296,16 +296,30 @@ TEST(is_linked_worktree_true_for_linked_worktree) { } bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* A subdirectory has no .git of its own. The predicate must still see the + * linked worktree above it, or ignore_worktrees misses nested sessions. */ + char nested[1024]; + snprintf(nested, sizeof(nested), "%s/nested/deeper", wt_tmp); + bool nested_ok = th_mkdir_p(nested) == 0; + bool nested_detected = nested_ok && cbm_git_is_linked_worktree(nested); /* 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); + char main_nested[1024]; + snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); + bool main_nested_ok = th_mkdir_p(main_nested) == 0; + bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); git_run(main_tmp, "worktree prune"); th_rmtree(main_tmp); th_rmtree(wt_tmp); ASSERT(worktree_detected); + ASSERT(nested_ok); + ASSERT(nested_detected); ASSERT(!main_detected); + ASSERT(main_nested_ok); + ASSERT(!main_nested_detected); PASS(); #endif /* _WIN32 */ } @@ -351,6 +365,10 @@ TEST(is_linked_worktree_false_for_submodule_and_nongit) { char subm[1024]; snprintf(subm, sizeof(subm), "%s/subm", super_tmp); bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + char sub_nested[1024]; + snprintf(sub_nested, sizeof(sub_nested), "%s/nested", subm); + bool sub_nested_made = sub_rc == 0 && th_mkdir_p(sub_nested) == 0; + bool sub_nested_detected = sub_nested_made && cbm_git_is_linked_worktree(sub_nested); th_rmtree(super_tmp); th_rmtree(child_tmp); @@ -359,8 +377,11 @@ TEST(is_linked_worktree_false_for_submodule_and_nongit) { 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. */ + /* A submodule gitlink has no commondir → must not be treated as a worktree, + * including a subdirectory, which must not walk into the superproject. */ ASSERT(!submodule_detected); + ASSERT(sub_nested_made); + ASSERT(!sub_nested_detected); PASS(); #endif /* _WIN32 */ } From 47c7a07a745c143a499977f920f0f8273cdadc8f Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:12:54 +0000 Subject: [PATCH 07/17] fix(cli): do not treat a parent graph as a linked worktree ignore_worktrees checked the setting only after project resolution, so a linked worktree nested in an indexed checkout was reported as that parent project. Drop an ancestor match and keep an index whose root is the worktree itself. Signed-off-by: Nohj --- src/cli/hook_augment.c | 19 ++++++++++++++++--- tests/test_cli.c | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index a389e487ff..b08d6ebd10 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1490,9 +1490,22 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root owned_config = cbm_mcp_server_attach_runtime_config(srv); } 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); + char project_root[4096]; + project_root[0] = '\0'; + char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, + sizeof(project_root)) + : NULL; + /* ignore_worktrees refuses a linked worktree, including one nested inside an + * indexed checkout. The ancestor walk would otherwise report that parent + * graph as this cwd's project. An index whose own root is the linked + * worktree (or inside it) still counts. */ + bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); + if (ignore_linked && project && !cbm_git_is_linked_worktree(project_root)) { + free(project); + project = NULL; + } + bool worktree_ignored = ignore_linked && !project; if (owned_config) { cbm_mcp_server_set_config(srv, NULL); cbm_config_close(owned_config); diff --git a/tests/test_cli.c b/tests/test_cli.c index 5d7441ec41..6297a64d09 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10644,6 +10644,41 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(ignored_out, "is indexed"); free(ignored_out); + /* A linked worktree nested inside the indexed checkout must not inherit + * that parent graph once ignore_worktrees is on. */ + char insider[512]; + snprintf(insider, sizeof(insider), "%s/inside-wt", maindir); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7inside", maindir, + insider); + bool inside_made = configured && system(cmd) == 0; + char inside_sub[600]; + snprintf(inside_sub, sizeof(inside_sub), "%s/nested", insider); + bool inside_sub_made = inside_made && test_mkdirp(inside_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + inside_sub); + char *inside_out = inside_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool inside_ignored = inside_out && strstr(inside_out, "ignore_worktrees is enabled") && + strstr(inside_out, "do not run index_repository") && + !strstr(inside_out, "is indexed") && !strstr(inside_out, main_name); + free(inside_out); + + /* Indexing that nested worktree itself must still win over the parent. */ + char *inside_name = inside_made ? cbm_project_name_from_path(insider) : NULL; + bool inside_stored = false; + if (inside_name) { + char db_inside[900]; + snprintf(db_inside, sizeof(db_inside), "%s/%s.db", cache, inside_name); + cbm_store_t *si = cbm_store_open_path(db_inside); + inside_stored = si && cbm_store_upsert_project(si, inside_name, insider) == CBM_STORE_OK; + if (si) + cbm_store_close(si); + } + char *exact_out = inside_stored ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool exact_kept = exact_out && inside_name && strstr(exact_out, inside_name) && + strstr(exact_out, "is indexed") && + !strstr(exact_out, "ignore_worktrees is enabled"); + free(exact_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10652,6 +10687,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { restore_test_env("CBM_CACHE_DIR", saved_cache); free(main_name); + free(inside_name); snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", maindir, wtdir); (void)system(cmd); @@ -10661,6 +10697,10 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); if (!honored) FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!inside_ignored) + FAIL("a nested linked worktree must not inherit the indexed parent graph"); + if (!exact_kept) + FAIL("an exact index of the nested worktree must still be reported"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); From 51a18c7b27728534ffefd7b2c4963277a91aa176 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:18:45 +0000 Subject: [PATCH 08/17] fix(cli): keep a worktree graph only for that same worktree The parent-graph drop treated any linked worktree root as a match, so an unindexed worktree nested inside an indexed one still inherited that parent. Compare the gitdir of each path's own anchor and keep the project only when both paths are that same linked worktree. Signed-off-by: Nohj --- src/cli/hook_augment.c | 7 +++---- src/git/git_context.c | 37 +++++++++++++++++++++++++++++++++++-- src/git/git_context.h | 6 ++++++ tests/test_cli.c | 21 +++++++++++++++++++++ tests/test_git_context.c | 18 ++++++++++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index b08d6ebd10..d3baace47c 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1496,12 +1496,11 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root sizeof(project_root)) : NULL; /* ignore_worktrees refuses a linked worktree, including one nested inside an - * indexed checkout. The ancestor walk would otherwise report that parent - * graph as this cwd's project. An index whose own root is the linked - * worktree (or inside it) still counts. */ + * indexed checkout or inside another linked worktree. Keep a match only when + * its root belongs to this cwd's own linked worktree, not a parent graph. */ bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - if (ignore_linked && project && !cbm_git_is_linked_worktree(project_root)) { + if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { free(project); project = NULL; } diff --git a/src/git/git_context.c b/src/git/git_context.c index 3fbf5b65d6..8e2629cc48 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -211,7 +211,8 @@ static bool parent_directory(char *path) { return true; } -bool cbm_git_is_linked_worktree(const char *path) { +/* On a linked-worktree hit, optionally copy that anchor directory into root_out. */ +static bool walk_linked_worktree_root(const char *path, char *root_out, size_t root_out_size) { if (!path || !path[0]) { return false; } @@ -230,7 +231,16 @@ bool cbm_git_is_linked_worktree(const char *path) { for (int depth = 0; depth < 64; depth++) { int kind = linked_worktree_anchor(current); if (kind >= 0) { - return kind == 1; + if (kind != 1) { + return false; + } + if (root_out && root_out_size > 0U) { + int written = snprintf(root_out, root_out_size, "%s", current); + if (written < 0 || (size_t)written >= root_out_size) { + return false; + } + } + return true; } char previous[GIT_OUTPUT_MAX]; snprintf(previous, sizeof(previous), "%s", current); @@ -241,6 +251,29 @@ bool cbm_git_is_linked_worktree(const char *path) { return false; } +bool cbm_git_is_linked_worktree(const char *path) { + return walk_linked_worktree_root(path, NULL, 0U); +} + +bool cbm_git_same_linked_worktree(const char *a, const char *b) { + char root_a[GIT_OUTPUT_MAX]; + char root_b[GIT_OUTPUT_MAX]; + if (!walk_linked_worktree_root(a, root_a, sizeof(root_a)) || + !walk_linked_worktree_root(b, root_b, sizeof(root_b))) { + return false; + } + /* The gitdir file is unique per linked worktree. Comparing those pointers + * keeps a nested worktree from matching its parent even when both are + * linked checkouts. */ + char git_a[GIT_OUTPUT_MAX]; + char git_b[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(root_a, git_a, sizeof(git_a)) || + !read_gitlink_target(root_b, git_b, sizeof(git_b))) { + return false; + } + return strcmp(git_a, git_b) == 0; +} + 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 30f2f545a1..1b9d174f17 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -32,6 +32,12 @@ typedef struct { * cbm_git_context_resolve() shells out to git and is not usable on that path. */ bool cbm_git_is_linked_worktree(const char *path); +/* True when both paths sit in the same linked worktree. Identity is the + * gitdir pointer of each path's linked-worktree anchor, so a worktree nested + * inside another does not count as that parent. False when either path is not + * inside a linked worktree. */ +bool cbm_git_same_linked_worktree(const char *a, const char *b); + 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/tests/test_cli.c b/tests/test_cli.c index 6297a64d09..e234d77df8 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10679,6 +10679,25 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(exact_out, "ignore_worktrees is enabled"); free(exact_out); + /* An unindexed worktree nested inside that indexed worktree must not + * inherit the outer worktree's graph. */ + char child[600]; + snprintf(child, sizeof(child), "%s/child-wt", insider); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7child", maindir, + child); + bool child_made = inside_stored && system(cmd) == 0; + char child_sub[700]; + snprintf(child_sub, sizeof(child_sub), "%s/sub", child); + bool child_sub_made = child_made && test_mkdirp(child_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + child_sub); + char *child_out = child_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool child_ignored = child_out && inside_name && + strstr(child_out, "ignore_worktrees is enabled") && + strstr(child_out, "do not run index_repository") && + !strstr(child_out, "is indexed") && !strstr(child_out, inside_name); + free(child_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10701,6 +10720,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) FAIL("an exact index of the nested worktree must still be reported"); + if (!child_ignored) + FAIL("a worktree nested inside an indexed worktree must not inherit that graph"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 5b3c4b7790..10cab00097 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -309,6 +309,19 @@ TEST(is_linked_worktree_true_for_linked_worktree) { snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); bool main_nested_ok = th_mkdir_p(main_nested) == 0; bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); + /* A worktree nested inside another must not share the parent's anchor. */ + char inner[1024]; + snprintf(inner, sizeof(inner), "%s/inner-wt", wt_tmp); + char inner_cmd[1200]; + snprintf(inner_cmd, sizeof(inner_cmd), "worktree add -b inner-branch \"%s\"", inner); + bool inner_added = git_run(main_tmp, inner_cmd) == 0; + char inner_sub[1200]; + snprintf(inner_sub, sizeof(inner_sub), "%s/sub", inner); + bool inner_sub_ok = inner_added && th_mkdir_p(inner_sub) == 0; + bool same_outer = cbm_git_same_linked_worktree(nested, wt_tmp); + bool same_inner = inner_sub_ok && cbm_git_same_linked_worktree(inner_sub, inner); + bool not_parent = inner_sub_ok && !cbm_git_same_linked_worktree(inner_sub, wt_tmp); + bool not_main = !cbm_git_same_linked_worktree(nested, main_tmp); git_run(main_tmp, "worktree prune"); th_rmtree(main_tmp); @@ -320,6 +333,11 @@ TEST(is_linked_worktree_true_for_linked_worktree) { ASSERT(!main_detected); ASSERT(main_nested_ok); ASSERT(!main_nested_detected); + ASSERT(inner_added); + ASSERT(same_outer); + ASSERT(same_inner); + ASSERT(not_parent); + ASSERT(not_main); PASS(); #endif /* _WIN32 */ } From 72f1a52dc6bb898166a2998b335c0682110a5c31 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:25:14 +0000 Subject: [PATCH 09/17] fix(git): identify a worktree by its canonical gitdir A 64-level walk missed a deep session path, and a relative gitdir joined onto a symlink spelling no longer matched the real project root. Walk to the filesystem root and compare canonical gitdirs. Drop the unused project resolver that failed the macOS -Werror build. Signed-off-by: Nohj --- src/cli/hook_augment.c | 4 ---- src/git/git_context.c | 14 +++++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index d3baace47c..10e7741a19 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1002,10 +1002,6 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c return ha_registry_project_for_path(srv, cwd, root_out, root_out_size); } -static char *ha_resolve_indexed_project(cbm_mcp_server_t *srv, const char *cwd) { - return ha_resolve_indexed_project_with_root(srv, cwd, NULL, 0U); -} - static const char *ha_hook_event_name(yyjson_val *root) { const char *event = ha_obj_str(root, "hook_event_name"); return event ? event : ha_obj_str(root, "hookEventName"); diff --git a/src/git/git_context.c b/src/git/git_context.c index 8e2629cc48..a63d3385e9 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -228,7 +228,9 @@ static bool walk_linked_worktree_root(const char *path, char *root_out, size_t r /* A session cwd or index_repository path is often a subdirectory. Walk * ancestors until a git anchor so ignore_worktrees applies to the whole * linked checkout, not only its root. */ - for (int depth = 0; depth < 64; depth++) { + /* Bound is the path itself: each step drops one component and stops at + * the filesystem root. A fixed depth would miss a deep session cwd. */ + for (;;) { int kind = linked_worktree_anchor(current); if (kind >= 0) { if (kind != 1) { @@ -248,7 +250,6 @@ static bool walk_linked_worktree_root(const char *path, char *root_out, size_t r return false; } } - return false; } bool cbm_git_is_linked_worktree(const char *path) { @@ -271,7 +272,14 @@ bool cbm_git_same_linked_worktree(const char *a, const char *b) { !read_gitlink_target(root_b, git_b, sizeof(git_b))) { return false; } - return strcmp(git_a, git_b) == 0; + /* A relative gitdir is joined onto the walked path. Canonicalize both so + * a symlink spelling of the cwd still matches the real project root. + * Fall back to the raw string when the gitdir does not exist. */ + char norm_a[GIT_OUTPUT_MAX]; + char norm_b[GIT_OUTPUT_MAX]; + const char *left = cbm_canonical_path(git_a, norm_a, sizeof(norm_a)) ? norm_a : git_a; + const char *right = cbm_canonical_path(git_b, norm_b, sizeof(norm_b)) ? norm_b : git_b; + return strcmp(left, right) == 0; } static char *join_root_relative(const char *root, const char *rel) { From 394faaacfbec823e3d138407bb21ad46869f9022 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:35:29 +0000 Subject: [PATCH 10/17] fix(cli): canonicalize cwd before the worktree gate A symlink into a linked worktree has no .git on its lexical path, so the hook told the agent to run index_repository. That call canonicalizes first and then refuses. Resolve the cwd before project lookup and the ignore_worktrees checks. Signed-off-by: Nohj --- src/cli/hook_augment.c | 8 ++++++++ tests/test_cli.c | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 10e7741a19..3de1691aa1 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1486,6 +1486,14 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root owned_config = cbm_mcp_server_attach_runtime_config(srv); } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); + /* A symlink alias has no .git of its own, and walking its lexical parents + * leaves the linked worktree. index_repository canonicalizes before the + * same gate, so the hook must too or it tells the agent to index a path + * the setting will refuse. */ + char canonical_cwd[4096]; + if (cwd && ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } char project_root[4096]; project_root[0] = '\0'; char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, diff --git a/tests/test_cli.c b/tests/test_cli.c index e234d77df8..27883c5c87 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10644,6 +10644,22 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(ignored_out, "is indexed"); free(ignored_out); + /* A symlink to a directory inside that worktree has no lexical .git. + * The hook must still follow the real path and honor ignore_worktrees. */ + char alias_target[600]; + snprintf(alias_target, sizeof(alias_target), "%s/nested", wtdir); + bool alias_target_ok = test_mkdirp(alias_target) == 0; + char alias[512]; + snprintf(alias, sizeof(alias), "%s/wt-alias", tmpdir); + bool alias_made = alias_target_ok && symlink(alias_target, alias) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + alias); + char *alias_out = alias_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool alias_ignored = alias_out && strstr(alias_out, "ignore_worktrees is enabled") && + strstr(alias_out, "do not run index_repository") && + !strstr(alias_out, "Run index_repository"); + free(alias_out); + /* A linked worktree nested inside the indexed checkout must not inherit * that parent graph once ignore_worktrees is on. */ char insider[512]; @@ -10716,6 +10732,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); if (!honored) FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!alias_ignored) + FAIL("a symlink into an ignored worktree must not recommend index_repository"); if (!inside_ignored) FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) From 3bab06134bdf1401e568884e1e624c464522635e Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 12:00:58 +0000 Subject: [PATCH 11/17] style(cli): break the worktree gate assignment for clang-format The ignore_linked initializer exceeded the formatter's assignment break. Lint treats that as an error, so the line now matches .clang-format. Signed-off-by: Nohj --- src/cli/hook_augment.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 3de1691aa1..342a7bd61b 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1502,8 +1502,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root /* ignore_worktrees refuses a linked worktree, including one nested inside an * indexed checkout or inside another linked worktree. Keep a match only when * its root belongs to this cwd's own linked worktree, not a parent graph. */ - bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && - cbm_git_is_linked_worktree(cwd); + bool ignore_linked = + srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { free(project); project = NULL; From b21c8ccb6ec37b53d026984278bd795db5f3abc1 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 12:19:57 +0000 Subject: [PATCH 12/17] fix(cli): drop a parent worktree graph without a new free The memory-core ratchet rejects another raw free() in the hook. Keep the parent name until the existing free(project) and skip it when the match is a different worktree. Signed-off-by: Nohj --- src/cli/hook_augment.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 342a7bd61b..384ba8467b 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1504,11 +1504,11 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root * its root belongs to this cwd's own linked worktree, not a parent graph. */ bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { - free(project); - project = NULL; - } - bool worktree_ignored = ignore_linked && !project; + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the single free(project) below still releases it. */ + bool drop_ancestor = + ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + bool worktree_ignored = ignore_linked && (!project || drop_ancestor); if (owned_config) { cbm_mcp_server_set_config(srv, NULL); cbm_config_close(owned_config); @@ -1531,7 +1531,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root scope = "Compaction"; } const char *tier = ha_active_tier(root, event); - if (project) { + if (project && !drop_ancestor) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); snprintf(context, sizeof(context), From b2e143371115d43d3518ef962e296cda4dd4ddef Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 19:30:37 -0400 Subject: [PATCH 13/17] Fix portable stat declarations for worktree detection Signed-off-by: Nohj --- src/git/git_context.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/git/git_context.c b/src/git/git_context.c index 38802e5aa6..640befd6cf 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -10,6 +10,7 @@ #include #include #include +#include enum { GIT_CMD_MAX = 1024, From 470500eaae0b25c69772ba5dca1b01a7554c9637 Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 19:55:35 -0400 Subject: [PATCH 14/17] Restore runtime config attachment for hook augmentation Signed-off-by: Nohj --- src/mcp/mcp.c | 11 +++++++++++ tests/test_mcp.c | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cccff18386..c45ec6de89 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1849,6 +1849,17 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { + if (!srv || srv->config) { + return NULL; + } + cbm_config_t *config = cbm_config_open(cbm_resolve_cache_dir()); + if (config) { + cbm_mcp_server_set_config(srv, config); + } + return config; +} + #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, diff --git a/tests/test_mcp.c b/tests/test_mcp.c index a03c1731a9..8eab275a2c 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1202,6 +1202,41 @@ TEST(mcp_initialize_response) { PASS(); } +TEST(mcp_attach_runtime_config_respects_caller_ownership) { + char *cache = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(cache); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *config = cbm_config_open(cache); + cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); + bool stored = config && cbm_config_set(config, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + bool default_off = server && !cbm_mcp_ignore_worktrees_enabled(server); + cbm_config_t *attached = server ? cbm_mcp_server_attach_runtime_config(server) : NULL; + bool enabled = attached && cbm_mcp_ignore_worktrees_enabled(server); + bool not_replaced = server && cbm_mcp_server_attach_runtime_config(server) == NULL; + if (server) { + cbm_mcp_server_set_config(server, config); + } + cbm_config_close(attached); + bool caller_config_kept = server && cbm_mcp_server_attach_runtime_config(server) == NULL && + cbm_mcp_ignore_worktrees_enabled(server); + cbm_mcp_server_free(server); + cbm_config_close(config); + restore_cache_dir(saved_copy); + free(saved_copy); + int removed = th_rmtree(cache); + + ASSERT(stored); + ASSERT(default_off); + ASSERT(enabled); + ASSERT(not_replaced); + ASSERT(caller_config_kept); + ASSERT_EQ(removed, 0); + PASS(); +} + TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); @@ -21147,6 +21182,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); + RUN_TEST(mcp_attach_runtime_config_respects_caller_ownership); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); From adc2764a3eff3791cc5692ec7e997887a1f6df3f Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 20:32:09 -0400 Subject: [PATCH 15/17] Fix C++ sanitizer runtime linkage in fuzz build Signed-off-by: Nohj --- Makefile.cbm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index ab5c89db73..b2a655505b 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1381,10 +1381,10 @@ fuzz: CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)" $(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \ + $(CXX) $(CFLAGS_TEST) -x c -fsanitize=fuzzer -o $@ \ $(FUZZ_SRC) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ - $(OBJS_VENDORED_TEST) \ + -x none $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) # Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads. From 9588007b16a07c3a85980ceb980e21cd8102c17a Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 21:45:52 -0400 Subject: [PATCH 16/17] Free partial Cypher patterns after parse errors Signed-off-by: Nohj --- src/cypher/cypher.c | 25 +++++++++++++++++-------- tests/test_cypher.c | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 7c7a2f9878..42ecb93c5b 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1862,6 +1862,8 @@ static int parse_return(parser_t *p, cbm_return_clause_t **out) { return parse_return_or_with(p, out, false); } +static void free_pattern(cbm_pattern_t *pat); + /* Parse a single MATCH pattern into pat */ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { memset(pat, 0, sizeof(*pat)); @@ -1870,31 +1872,38 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t)); pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t)); + /* Count the slot before parsing so even partially filled nodes and + * relationships are released when a malformed pattern is rejected. */ + pat->node_count = SKIP_ONE; if (parse_node(p, &pat->nodes[0]) < 0) { - return CBM_NOT_FOUND; + goto fail; } - pat->node_count = SKIP_ONE; while (check(p, TOK_DASH) || check(p, TOK_LT)) { if (pat->rel_count >= rel_cap) { rel_cap *= PAIR_LEN; pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t)); } - if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) { - return CBM_NOT_FOUND; - } pat->rel_count++; + if (parse_rel(p, &pat->rels[pat->rel_count - SKIP_ONE]) < 0) { + goto fail; + } if (pat->node_count >= node_cap) { node_cap *= PAIR_LEN; pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t)); } - if (parse_node(p, &pat->nodes[pat->node_count]) < 0) { - return CBM_NOT_FOUND; - } pat->node_count++; + if (parse_node(p, &pat->nodes[pat->node_count - SKIP_ONE]) < 0) { + goto fail; + } } return 0; + +fail: + free_pattern(pat); + memset(pat, 0, sizeof(*pat)); + return CBM_NOT_FOUND; } /* Parse UNWIND [...] AS var clause into query */ diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 7c251bd9b0..5d198a0ef6 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -274,6 +274,24 @@ TEST(cypher_parse_accepts_single_with_clause) { PASS(); } +TEST(cypher_parse_rejects_partial_patterns) { + const char *queries[] = { + "MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)", + "MATCH (a)-[:CALLS]->(b) MATCH (c)-[:CALLS*]-> RETURN a.name", + "MATCH (a)-[:CALLS|]->(b)", + }; + for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = cbm_cypher_parse(queries[i], &q, &err); + ASSERT_NEQ(rc, 0); + ASSERT_NULL(q); + ASSERT_NOT_NULL(err); + free(err); + } + PASS(); +} + TEST(cypher_parse_relationship_outbound) { cbm_query_t *q = NULL; char *err = NULL; @@ -4842,6 +4860,7 @@ SUITE(cypher) { RUN_TEST(cypher_parse_rejects_trailing_tokens); RUN_TEST(cypher_parse_rejects_second_with_clause); RUN_TEST(cypher_parse_accepts_single_with_clause); + RUN_TEST(cypher_parse_rejects_partial_patterns); RUN_TEST(cypher_parse_relationship_outbound); RUN_TEST(cypher_parse_relationship_inbound); RUN_TEST(cypher_parse_relationship_any); From 62479ff9fce0f8a052b0eab29a5499ca48b93bec Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 22:38:59 -0400 Subject: [PATCH 17/17] Isolate Git context probes from inherited repository environment Signed-off-by: Nohj --- src/git/git_context.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index a63d3385e9..6986be4aaa 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -65,7 +65,9 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return CBM_NOT_FOUND; } - FILE *fp = cbm_popen(cmd, "r"); + /* A hook or editor may export GIT_DIR/GIT_WORK_TREE for another repo. + * Isolate this child so git -C selects repo_path rather than that repo. */ + FILE *fp = cbm_popen_git(cmd); if (!fp) { return CBM_NOT_FOUND; }