Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
4 changes: 3 additions & 1 deletion src/cli/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions src/cli/hook_augment.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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, "
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions src/git/git_context.c
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) {
#endif
}

/* Read the "gitdir: <path>" pointer out of a gitlink FILE at <path>/.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 <gitdir>/commondir; a submodule gitlink
* points at <super>/.git/modules/<name>, 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);
Expand Down
12 changes: 12 additions & 0 deletions src/git/git_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>/.git must be a regular file holding a
* "gitdir: <path>" 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
* (<super>/.git/modules/<name>) 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);
Expand Down
36 changes: 35 additions & 1 deletion src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/mcp_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
22 changes: 20 additions & 2 deletions tests/test_cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading