diff --git a/README.md b/README.md index 73e7e7f64..7f5520349 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,8 @@ To turn the watcher off entirely, set `config set watcher_enabled false` (defaul The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules. +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: @@ -1016,6 +1018,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 # remove/disable all background watches (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index aa4a72d81..5656d0bf8 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` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. | | `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | | `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. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. | @@ -142,6 +143,44 @@ 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. + +A subdirectory counts as the worktree it sits in. Detection walks up from the +given path to the nearest `.git` entry, so `index_repository` on any +subdirectory of a linked worktree is refused the same way, and a session root or +hook working directory inside one is treated as that worktree. The walk stops at +the first `.git` it finds; a main checkout or a submodule ends it as "not a +worktree". A path outside any git checkout is checked one directory at a time up +to the filesystem root and is never treated as a worktree. None of this runs +while the key is off. + +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 587e25918..0461ec3af 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8088,6 +8088,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"}, /* Unset: the index mode decides (cbm_pipeline_doc_candidates_enabled). */ {CBM_CONFIG_DOC_CANDIDATES, "auto", "Doc-code candidates: auto = on for full, off for moderate/fast; true/false forces"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index 52ace9003..7d0c4e8da 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -336,7 +336,11 @@ 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); +/* Counts lifecycle calls that reached the ignore_worktrees resolution (cwd + * canonicalization and the linked-worktree probe). NULL stops counting. */ +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); /* include_posttool false renders the previous lifecycle-only manifest. Release * the result with cbm_free(CBM_MEM_CLASS_OTHER, ...). */ @@ -526,6 +530,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_DOC_CANDIDATES "doc_candidates" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index ddee6aaa0..58e75c6d2 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -17,11 +17,14 @@ */ #include "cli/cli.h" +#include "foundation/compat.h" #include "foundation/compat_fs.h" #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" @@ -1654,7 +1657,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." @@ -1671,6 +1681,41 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * return !forced_event || ha_dialect_event_supported(dialect, forced_event); } +#ifdef CBM_CLI_ENABLE_TEST_API +static CBM_TLS int *g_ha_worktree_gate_counter = NULL; +#endif + +/* Project resolution once ignore_worktrees is on; the caller runs this only + * after reading the key, so the default path does none of it. 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. The + * setting refuses a linked worktree even when it sits inside an indexed + * checkout or inside another linked worktree, so a match is kept only when its + * root belongs to this cwd's own linked worktree, not a parent graph. */ +static char *ha_resolve_project_ignoring_worktrees(cbm_mcp_server_t *srv, const char *cwd, + bool *drop_ancestor, bool *worktree_ignored) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_ha_worktree_gate_counter) { + (*g_ha_worktree_gate_counter)++; + } +#endif + char canonical_cwd[4096]; + if (ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } + char project_root[4096]; + project_root[0] = '\0'; + char *project = + ha_resolve_indexed_project_with_root(srv, cwd, project_root, sizeof(project_root)); + bool linked = cbm_git_is_linked_worktree(cwd); + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the caller's single free(project) still releases it. */ + *drop_ancestor = linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + *worktree_ignored = linked && (!project || *drop_ancestor); + return project; +} + static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root, const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { @@ -1688,7 +1733,20 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root srv = owned_server; } 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; + /* Read ignore_worktrees first and do nothing else for it while it is off. + * cbm_mcp_server_new(NULL) has no store, and srv may be the daemon's + * session server, so the read leaves srv untouched (see + * cbm_mcp_ignore_worktrees_runtime_enabled). Off, this is the resolution + * the hook did before the key existed. */ + bool drop_ancestor = false; + bool worktree_ignored = false; + char *project = NULL; + if (srv && cwd && cbm_mcp_ignore_worktrees_runtime_enabled(srv)) { + project = + ha_resolve_project_ignoring_worktrees(srv, cwd, &drop_ancestor, &worktree_ignored); + } else { + project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + } cbm_mcp_server_free(owned_server); char context[2048]; @@ -1713,7 +1771,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root ? " The same tools are also available from the shell: " "`codebase-memory-mcp cli --help` lists each tool's flags." : ""; - if (project) { + if (project && !drop_ancestor) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); /* The untrusted label covers the graph-derived project name only. Wrapping @@ -1732,7 +1790,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.%s", scope, safe_project, tier, cli_hint); } 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, " @@ -1851,8 +1909,13 @@ 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); +} + +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter) { + g_ha_worktree_gate_counter = counter; } 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 a5216846b..3b9409f83 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -17,6 +17,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" @@ -1941,6 +1942,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; @@ -2599,6 +2612,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 66431333b..9b0b79b22 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -73,6 +73,8 @@ static int git_capture_into(const char *repo_path, const char *git_args, char *b * stuck git child is named by the last marker printed (spawn, read or * close). Silent at the default level. */ cbm_log_debug("git.capture", "stage", "spawn", "args", git_args); + /* 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; @@ -111,6 +113,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; @@ -125,6 +135,180 @@ 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; + } + if (!path_is_regular_file(dot_git)) { + 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; +} + +/* 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 0; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + 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; + } +#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; +} + +/* 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; + } + 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. */ + /* 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) { + 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); + if (!parent_directory(current) || strcmp(previous, current) == 0) { + 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; + } + /* 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) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 2ac419522..0f3c40378 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -20,6 +20,25 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is a LINKED git worktree (`git worktree add`) or a + * subdirectory of one. + * + * 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. */ +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); /* Only the is_git and branch fields of cbm_git_context_resolve, from the same * two git commands it uses for them (2 git processes instead of 7). branch diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 29685e4c8..c82f90f11 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -551,6 +551,9 @@ static const tool_def_t TOOLS[] = { "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"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.\"}," "\"doc_candidates\":{\"type\":\"boolean\",\"description\":" "\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for " "moderate/fast.\"}," @@ -13050,6 +13053,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) { if (async_mode) { index_args_free(repo_path, mode_str, name_override); @@ -21551,6 +21568,44 @@ 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); +} + +/* The same key for a caller whose server may have no store: the stand-alone + * lifecycle hook builds one with cbm_mcp_server_new(NULL), and the daemon lends + * its session server, which this must not modify. A server with a store answers + * from it. Otherwise the runtime store is opened only to read this key, and only + * when it exists: a missing store leaves every key at its default, so this read + * never creates one. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) { + if (srv && srv->config) { + return cbm_mcp_ignore_worktrees_enabled(srv); + } + const char *store_dir = cbm_resolve_cache_dir(); + if (!store_dir) { + return false; + } + char store_path[CBM_SZ_4K]; + int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, store_dir); + cbm_path_info_t info; + if (written < 0 || (size_t)written >= sizeof(store_path) || + cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) { + return false; + } + cbm_config_t *config = cbm_config_open(store_dir); + bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); + cbm_config_close(config); + return enabled; +} + /* 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) { @@ -21705,6 +21760,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 70a83832e..992684f1f 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -86,6 +86,16 @@ 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); + +/* The same key when srv may have no store (the lifecycle hook). Never attaches + * a store to srv and never creates the runtime store; reads it only when it + * already exists. */ +bool cbm_mcp_ignore_worktrees_runtime_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 f716418ad..3284ef72a 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -12677,9 +12678,91 @@ 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); + + /* 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]; + 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); + + /* 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); @@ -12688,6 +12771,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); @@ -12695,10 +12779,110 @@ 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 (!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) + 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(); } + +/* With ignore_worktrees unset, the lifecycle hook must do what it did before + * the key existed. It may read the key, but it must not create a runtime + * config store just to find the key unset, and it must not canonicalize the + * cwd or probe for a linked worktree. Turning the key on is the positive + * control: the same call then reaches the worktree gate exactly once. */ +TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) { + char tmpdir[256]; + char *raw = th_mktempdir("cbm_hook_wt_off"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(tmpdir, sizeof(tmpdir), "%s", raw); + char cache[512]; + char maindir[512]; + char wtdir[512]; + char store_path[600]; + snprintf(cache, sizeof(cache), "%s/cache", tmpdir); + snprintf(maindir, sizeof(maindir), "%s/checkout-main", tmpdir); + snprintf(wtdir, sizeof(wtdir), "%s/checkout-side", tmpdir); + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); + test_mkdirp(cache); + test_mkdirp(maindir); + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "cd \"%s\" && git init -q && git config user.email t@t && git config user.name t && " + ": > f.txt && git add f.txt && git commit -qm init && " + "git worktree add -q \"%s\" -b wtoff", + maindir, wtdir); + if (system(cmd) != 0) { + th_rmtree(tmpdir); + SKIP_PLATFORM("git worktree add unavailable"); + } + + char *saved_cache = save_test_env("CBM_CACHE_DIR"); + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char input[1024]; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); + struct stat st; + int gate_calls = 0; + cbm_hook_set_worktree_gate_counter_for_testing(&gate_calls); + bool store_absent_before = stat(store_path, &st) != 0; + char *off_out = cbm_hook_augment_lifecycle_json(input); + bool off_note = off_out && strstr(off_out, "Run index_repository") && + !strstr(off_out, "ignore_worktrees is enabled"); + free(off_out); + bool store_absent_after = stat(store_path, &st) != 0; + int off_gate_calls = gate_calls; + + /* A store that leaves the key unset may be read for the key, nothing more. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool other_key_set = cfg && cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *unset_out = other_key_set ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool unset_note = unset_out && strstr(unset_out, "Run index_repository") && + !strstr(unset_out, "ignore_worktrees is enabled"); + free(unset_out); + int unset_gate_calls = gate_calls; + + cfg = cbm_config_open(cache); + bool enabled = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *on_out = enabled ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool on_note = on_out && strstr(on_out, "ignore_worktrees is enabled"); + free(on_out); + int on_gate_calls = gate_calls; + cbm_hook_set_worktree_gate_counter_for_testing(NULL); + + restore_test_env("CBM_CACHE_DIR", saved_cache); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", + maindir, wtdir); + (void)system(cmd); + th_rmtree(tmpdir); + + ASSERT_TRUE(store_absent_before); + ASSERT_TRUE(off_note); + if (!store_absent_after) + FAIL("a default-off lifecycle event must not create the runtime config store"); + ASSERT_EQ(off_gate_calls, 0); + ASSERT_TRUE(other_key_set); + ASSERT_TRUE(unset_note); + ASSERT_EQ(unset_gate_calls, 0); + ASSERT_TRUE(enabled); + ASSERT_TRUE(on_note); + ASSERT_EQ(on_gate_calls, 1); + PASS(); +} #endif TEST(cli_hook_session_sanitizes_untrusted_project_metadata) { @@ -15172,14 +15356,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(); } @@ -19282,6 +19470,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); @@ -20165,6 +20366,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_install_managed_binary_config_path_issue2722); @@ -20405,6 +20607,7 @@ SUITE(cli) { #ifndef _WIN32 RUN_TEST(cli_hook_worktree_cwd_resolves_own_indexed_project); RUN_TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent); + RUN_TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work); #endif RUN_TEST(cli_hook_session_sanitizes_untrusted_project_metadata); RUN_TEST(cli_hook_session_untrusted_label_covers_project_name_only); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index c508e25f3..5075e76e6 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -346,6 +346,95 @@ 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); + /* 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); + /* 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); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(nested_ok); + ASSERT(nested_detected); + 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 */ +} + TEST(git_context_ignores_inherited_repo_env) { #ifdef _WIN32 SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); @@ -403,6 +492,68 @@ TEST(git_context_ignores_inherited_repo_env) { #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); + 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); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir, including a subdirectory which + * must not walk into the superproject. */ + ASSERT(!submodule_detected); + ASSERT(sub_nested_made); + ASSERT(!sub_nested_detected); + PASS(); +#endif /* _WIN32 */ +} + TEST(githistory_ignores_inherited_repo_env) { #ifdef _WIN32 SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); @@ -503,6 +654,8 @@ 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); RUN_TEST(git_context_ignores_inherited_repo_env); RUN_TEST(githistory_ignores_inherited_repo_env); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index cdc3c5541..91ac812a9 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1214,6 +1214,57 @@ TEST(mcp_initialize_response) { PASS(); } +/* The lifecycle hook reads ignore_worktrees through this reader. It must never + * attach a store to the server (the daemon lends its session server) and never + * create the runtime store; a server with its own store answers from it. */ +TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone) { + char *raw = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(raw); + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + char own_dir[320]; + snprintf(own_dir, sizeof(own_dir), "%s/own", cache); + char store_path[400]; + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); + struct stat st; + bool absent_off = server && !cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool still_absent = stat(store_path, &st) != 0; + + cbm_config_t *runtime = cbm_config_open(cache); + bool stored = runtime && cbm_config_set(runtime, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(runtime); + bool runtime_on = server && cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool server_untouched = server && !cbm_mcp_ignore_worktrees_enabled(server); + + cbm_config_t *own = cbm_config_open(own_dir); + if (server && own) { + cbm_mcp_server_set_config(server, own); + } + bool own_store_wins = server && own && !cbm_mcp_ignore_worktrees_runtime_enabled(server); + if (server) { + cbm_mcp_server_set_config(server, NULL); + } + cbm_mcp_server_free(server); + cbm_config_close(own); + restore_cache_dir(saved_copy); + free(saved_copy); + int removed = th_rmtree(cache); + + ASSERT(absent_off); + ASSERT(still_absent); + ASSERT(stored); + ASSERT(runtime_on); + ASSERT(server_untouched); + ASSERT(own_store_wins); + ASSERT_EQ(removed, 0); + PASS(); +} + TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); @@ -24438,6 +24489,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 * @@ -26751,6 +27087,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); + RUN_TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); @@ -27149,6 +27486,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 RUN_TEST(mcp_auto_index_in_process_fresh_project_uses_full_width); RUN_TEST(mcp_auto_index_in_process_existing_index_keeps_headroom); RUN_TEST(mcp_background_flag_fresh_project_uses_full_width);