Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0d2f840
feat(config): add ignore_worktrees to skip linked git worktrees
vitalNohj Aug 4, 2026
16698e8
feat(config): add ignore_worktrees to skip linked git worktrees
vitalNohj Aug 4, 2026
d63d0e1
fix(git): probe worktree gitlinks without stat()
vitalNohj Sep 29, 2026
3bb85c8
fix(daemon): recheck ignore_worktrees on auto-index retry
vitalNohj Sep 29, 2026
5d5c962
fix(cli): load runtime config for the hook-augment worktree note
vitalNohj Sep 29, 2026
0071007
fix(git): detect linked worktrees from nested directories
vitalNohj Sep 29, 2026
47c7a07
fix(cli): do not treat a parent graph as a linked worktree
vitalNohj Sep 29, 2026
51a18c7
fix(cli): keep a worktree graph only for that same worktree
vitalNohj Sep 29, 2026
72f1a52
fix(git): identify a worktree by its canonical gitdir
vitalNohj Sep 29, 2026
394faaa
fix(cli): canonicalize cwd before the worktree gate
vitalNohj Sep 29, 2026
3bab061
style(cli): break the worktree gate assignment for clang-format
vitalNohj Sep 29, 2026
b21c8cc
fix(cli): drop a parent worktree graph without a new free
vitalNohj Sep 29, 2026
cb7dfae
Merge pull request #1 from vitalNohj/cloud/cbm-worktree-ignore-rework…
vitalNohj Sep 30, 2026
3f6969a
Merge upstream main into fm/cbm-worktree-ignore-1
vitalNohj Sep 30, 2026
b2e1433
Fix portable stat declarations for worktree detection
vitalNohj Sep 30, 2026
7ca336b
Merge worktree follow-ups into PR #1430
vitalNohj Sep 30, 2026
470500e
Restore runtime config attachment for hook augmentation
vitalNohj Sep 30, 2026
adc2764
Fix C++ sanitizer runtime linkage in fuzz build
vitalNohj Oct 1, 2026
9588007
Free partial Cypher patterns after parse errors
vitalNohj Oct 1, 2026
62479ff
Isolate Git context probes from inherited repository environment
vitalNohj Oct 1, 2026
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
4 changes: 2 additions & 2 deletions Makefile.cbm
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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 @@ -762,6 +764,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 @@ -7488,6 +7488,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 @@ -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);
Expand Down Expand Up @@ -444,6 +445,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
62 changes: 52 additions & 10 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 @@ -1000,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");
Expand Down Expand Up @@ -1440,7 +1438,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 All @@ -1466,12 +1471,48 @@ 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;
/* 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,
sizeof(project_root))
: NULL;
/* 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);
/* 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);
}
cbm_mcp_server_free(owned_server);

char context[2048];
Expand All @@ -1490,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),
Expand All @@ -1505,7 +1546,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 +1665,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
25 changes: 17 additions & 8 deletions src/cypher/cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 */
Expand Down
18 changes: 18 additions & 0 deletions src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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 @@ -1613,6 +1614,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;
Expand Down Expand Up @@ -2236,6 +2249,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
Loading
Loading