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 @@ -153,6 +153,8 @@ When enabled, new projects are indexed automatically on first connection. Previo

Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing.

Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees).

### Keeping Up to Date

**Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run:
Expand Down Expand Up @@ -663,6 +665,7 @@ codebase-memory-mcp config list # show all settings
codebase-memory-mcp config set auto_index true # auto-index on session start
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false)
codebase-memory-mcp config reset auto_index # reset to default
```

Expand Down
30 changes: 30 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,36 @@ Current keys:
|---|---|---|
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. |

### `ignore_worktrees`

Every indexed project is registered under its own absolute root path, so each
linked worktree becomes a separate permanent index. On machines that create many
short-lived worktrees, the automatic paths (`auto_index`, and the session hook's
"index this project first" guidance) turn every throwaway checkout into another
stored index of what is largely the same repository.

Enable the key to keep those checkouts out of the index:

```bash
codebase-memory-mcp config set ignore_worktrees true
```

With it enabled:

- automatic indexing skips a session whose root is a linked worktree;
- the `hook-augment` context says the worktree is unindexed on purpose instead
of telling the agent to run `index_repository`;
- an explicit `index_repository` call on a linked worktree is refused, and names
both ways forward — pass `index_worktree=true` for that one call, or turn the
key back off.

The main checkout of the same repository is unaffected, as are ordinary clones
and submodules. Detection is git plumbing only: a linked worktree's `.git` is a
file pointing at a gitdir that contains a `commondir` entry.

The default is `false`, so indexing behavior is unchanged unless you opt in.

## 3. UI Settings

Expand Down
4 changes: 4 additions & 0 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -6481,6 +6481,8 @@ int cbm_cmd_config(int argc, char **argv) {
"Max files for auto-indexing new projects");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_WATCH, "true",
"Register background git watcher on session connect");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_IGNORE_WORKTREES, "false",
"Skip linked git worktrees when indexing automatically");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
"Pin graph UI language: en, zh, or auto");
return 0;
Expand Down Expand Up @@ -6510,6 +6512,8 @@ int cbm_cmd_config(int argc, char **argv) {
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_WATCH,
cbm_config_get(cfg, CBM_CONFIG_AUTO_WATCH, "true"));
printf(" %-25s = %-10s\n", CBM_CONFIG_IGNORE_WORKTREES,
cbm_config_get(cfg, CBM_CONFIG_IGNORE_WORKTREES, "false"));
printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
} else if (strcmp(argv[0], "get") == 0) {
Expand Down
1 change: 1 addition & 0 deletions src/cli/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
#define CBM_CONFIG_AUTO_INDEX "auto_index"
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees"
#define CBM_CONFIG_UI_LANG "ui-lang"

/* ── Binary activation safety ─────────────────────────────────── */
Expand Down
17 changes: 14 additions & 3 deletions src/cli/hook_augment.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/mem.h"
#include "git/git_context.h"
#include "mcp/mcp.h"
#include "mcp/mcp_internal.h"
#include "pipeline/pipeline.h"
#include "yyjson/yyjson.h"

Expand Down Expand Up @@ -1123,7 +1125,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) {
return "Tier 2 verification";
}

static const char *ha_no_project_index_guidance(const char *event) {
static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) {
/* ignore_worktrees is on and this cwd is a linked worktree: indexing it is
* deliberately disabled, so telling the agent to run index_repository would
* only produce a refusal. Point at the setting instead. */
if (worktree_ignored) {
return "This is a linked git worktree and ignore_worktrees is enabled, so it is not "
"indexed on purpose; do not run index_repository here.";
}
return event && strcmp(event, "SubagentStart") == 0
? "Ask the parent agent to run index_repository before structural exploration; "
"do not attempt graph mutation."
Expand Down Expand Up @@ -1155,6 +1164,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
}
const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer));
char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL;
bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) &&
cbm_git_is_linked_worktree(cwd);
cbm_mcp_server_free(owned_server);

char context[2048];
Expand Down Expand Up @@ -1188,7 +1199,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
"and file reads for literals, configs, non-code files, and verification.",
scope, safe_project, tier);
} else {
const char *index_guidance = ha_no_project_index_guidance(event);
const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored);
snprintf(context, sizeof(context),
"[codebase-memory] %s context: no indexed graph project matched this working "
"directory. %s Once indexed, "
Expand Down Expand Up @@ -1308,7 +1319,7 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
}

const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) {
return ha_no_project_index_guidance(event);
return ha_no_project_index_guidance(event, false);
}
#endif

Expand Down
6 changes: 6 additions & 0 deletions src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "foundation/mem.h"
#include "foundation/platform.h"
#include "foundation/subprocess.h"
#include "git/git_context.h"
#include "mcp/index_supervisor.h"
#include "mcp/mcp.h"
#include "mcp/mcp_internal.h"
Expand Down Expand Up @@ -1935,6 +1936,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio
: CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT;
int tracked_files = -1;
bool auto_index_candidate = auto_index && !db_exists;
if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) &&
cbm_git_is_linked_worktree(root_path)) {
cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree");
auto_index_candidate = false;
}
bool within_auto_index_limit =
!auto_index_candidate ||
cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files);
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
38 changes: 37 additions & 1 deletion src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,10 @@ static const tool_def_t TOOLS[] = {
"are normalized.\"},"
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
"Teammates can bootstrap from the artifact instead of full re-indexing.\"},"
"\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":"
"\"Index repo_path even when it is a linked git worktree and the "
"ignore_worktrees config key is enabled. No effect otherwise.\"}"
"},\"required\":[\"repo_path\"]}"},

{"search_graph", "Search graph",
Expand Down Expand Up @@ -7916,6 +7919,22 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result(boundary_err, true);
}

/* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree
* is refused with the two ways forward (per-call override, or turn the
* setting off) rather than silently skipped — a silent success would be
* indistinguishable from a real index to the caller. */
if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") &&
cbm_git_is_linked_worktree(repo_path)) {
free(mode_str);
free(name_override);
free(repo_path);
return cbm_mcp_text_result(
"repo_path is a linked git worktree and ignore_worktrees is enabled. Pass "
"index_worktree=true to index it anyway, or run: codebase-memory-mcp config set "
"ignore_worktrees false",
true);
}

if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
free(mode_str);
char *result = handle_cross_repo_mode(srv, repo_path, name_override, args);
Expand Down Expand Up @@ -11094,6 +11113,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) {
return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true);
}

/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees
* (default off, so existing setups keep indexing them). Users who create many
* short-lived worktrees can stop each throwaway checkout from registering a
* new permanent project with `config set ignore_worktrees true`. */
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) {
if (!srv || !srv->config) {
return false; /* default off */
}
return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false);
}

/* Register the session project with the background watcher for ongoing
* change detection — unless auto_watch is disabled. */
static void register_watcher_if_enabled(cbm_mcp_server_t *srv) {
Expand Down Expand Up @@ -11220,6 +11250,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
return;
}

if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) {
cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project",
srv->session_project);
return;
}

/* Quick tracked-file count check to avoid OOM on massive repos. */
int file_count = -1;
if (!cbm_mcp_auto_index_within_file_limit(srv->session_root, file_limit, &file_count)) {
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 @@ -43,6 +43,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy);
bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit,
int *file_count_out);

/* True when the `ignore_worktrees` config key is on for this server (default
* off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an
* automatic index of a linked worktree should be skipped. */
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv);

/* detect_changes seed scoping (#1363): does `node`'s line range overlap any
* recorded hunk for `file`? Exposed for direct unit testing of the overlap
* logic, independent of the git/subprocess/index plumbing around it. */
Expand Down
Loading