From 9376ca1b3c02366a639411d9b43c28e7a89546f0 Mon Sep 17 00:00:00 2001 From: Andy11-cpu Date: Wed, 1 Jul 2026 21:53:18 -0400 Subject: [PATCH] Deduplicate project indexes by git/path identity Reuse an existing cached project when the same repository is opened from a different path. Match by git canonical root or canonical filesystem path so duplicate .db files are not created. Signed-off-by: Andy11-cpu --- Makefile.cbm | 3 +- src/mcp/mcp.c | 16 ++- src/pipeline/pipeline.c | 10 +- src/pipeline/project_resolve.c | 221 +++++++++++++++++++++++++++++++++ src/pipeline/project_resolve.h | 22 ++++ tests/test_project_resolve.c | 188 ++++++++++++++++++++++++++++ 6 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 src/pipeline/project_resolve.c create mode 100644 src/pipeline/project_resolve.h create mode 100644 tests/test_project_resolve.c diff --git a/Makefile.cbm b/Makefile.cbm index 22dfc509..4098f6be 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -211,6 +211,7 @@ GRAPH_BUFFER_SRCS = src/graph_buffer/graph_buffer.c # Pipeline module (new) PIPELINE_SRCS = \ src/pipeline/fqn.c \ + src/pipeline/project_resolve.c \ src/pipeline/path_alias.c \ src/pipeline/registry.c \ src/pipeline/pipeline.c \ @@ -370,7 +371,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_project_resolve.c TEST_WATCHER_SRCS = tests/test_watcher.c diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index fce62b64..8f54b578 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -43,6 +43,7 @@ enum { #include #include "cypher/cypher.h" #include "pipeline/pipeline.h" +#include "pipeline/project_resolve.h" #include "pipeline/pass_cross_repo.h" #include "git/git_context.h" #include "cli/cli.h" @@ -5624,10 +5625,17 @@ static void detect_session(cbm_mcp_server_t *srv) { * used by the pipeline, otherwise session queries look for a .db file * that doesn't match the indexed project name. */ if (srv->session_root[0]) { - char *pname = cbm_project_name_from_path(srv->session_root); - if (pname) { - snprintf(srv->session_project, sizeof(srv->session_project), "%s", pname); - free(pname); + char *existing = cbm_find_existing_project_name(srv->session_root); + if (existing) { + snprintf(srv->session_project, sizeof(srv->session_project), "%s", existing); + cbm_log_info("session.project.reuse", "project", existing, "path", srv->session_root); + free(existing); + } else { + char *pname = cbm_project_name_from_path(srv->session_root); + if (pname) { + snprintf(srv->session_project, sizeof(srv->session_project), "%s", pname); + free(pname); + } } } } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index e9f81f45..f4051fa1 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -15,6 +15,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, PL_WAL_BUF = 1040 }; #define PL_NSEC_PER_SEC 1000000000LL #include "pipeline/pipeline.h" +#include "pipeline/project_resolve.h" #include "pipeline/artifact.h" #include "pipeline/pipeline_internal.h" #include "pipeline/pass_lsp_cross.h" @@ -166,7 +167,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, p->repo_path = strdup(repo_path); p->db_path = db_path ? strdup(db_path) : NULL; - p->project_name = cbm_project_name_from_path(repo_path); + char *existing = cbm_find_existing_project_name(repo_path); + p->project_name = existing ? existing : cbm_project_name_from_path(repo_path); (void)cbm_git_context_resolve(repo_path, &p->git_ctx); p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx); p->mode = mode; @@ -1265,6 +1267,9 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { rc = try_incremental_or_delete_db(p, files, file_count); if (rc >= 0) { cbm_discover_free(files, file_count); + if (rc == 0) { + cbm_project_identity_cache_invalidate(); + } return rc; } cbm_log_info("pipeline.route", "path", "full"); @@ -1320,5 +1325,8 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cbm_set_user_lang_config(NULL); cbm_userconfig_free(p->userconfig); p->userconfig = NULL; + if (rc == 0) { + cbm_project_identity_cache_invalidate(); + } return rc; } diff --git a/src/pipeline/project_resolve.c b/src/pipeline/project_resolve.c new file mode 100644 index 00000000..125f7fd5 --- /dev/null +++ b/src/pipeline/project_resolve.c @@ -0,0 +1,221 @@ +/* + * project_resolve.c — Canonical path identity and duplicate-index prevention. + */ +#include "pipeline/project_resolve.h" +#include "foundation/platform.h" +#include "foundation/compat_fs.h" +#include "foundation/str_util.h" +#include "git/git_context.h" +#include "store/store.h" + +#include +#include +#include + +typedef struct { + char *identity_key; + char *project_name; +} proj_identity_entry_t; + +typedef struct { + bool loaded; + char cache_dir[1024]; + proj_identity_entry_t *entries; + size_t count; +} proj_identity_cache_t; + +static proj_identity_cache_t g_identity_cache; + +bool cbm_path_canonicalize(const char *path, char *out, size_t out_sz) { + if (!path || !out || out_sz == 0) { + return false; + } + out[0] = '\0'; +#ifdef _WIN32 + if (!_fullpath(out, path, out_sz)) { + return false; + } + cbm_normalize_path_sep(out); +#else + if (!realpath(path, real)) { + return false; + } +#endif + return out[0] != '\0'; +} + +bool cbm_project_identity_key(const char *repo_path, char *out, size_t out_sz) { + if (!repo_path || !out || out_sz == 0) { + return false; + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo_path, &ctx) == 0 && ctx.is_git && ctx.worktree_root && + ctx.worktree_root[0]) { + bool ok = cbm_path_canonicalize(ctx.worktree_root, out, out_sz); + cbm_git_context_free(&ctx); + return ok; + } + cbm_git_context_free(&ctx); + return cbm_path_canonicalize(repo_path, out, out_sz); +} + +static bool identity_is_child(const char *child, const char *parent) { + if (!child[0] || !parent[0]) { + return false; + } + if (strcmp(child, parent) == 0) { + return true; + } + size_t plen = strlen(parent); + if (strncmp(child, parent, plen) != 0) { + return false; + } + return child[plen] == '/'; +} + +static bool is_project_db_file(const char *name, size_t len) { + if (len < 5 || strcmp(name + len - 3, ".db") != 0) { + return false; + } + if (name[0] == '_') { + return false; + } + return true; +} + +static void identity_cache_free(void) { + for (size_t i = 0; i < g_identity_cache.count; i++) { + free(g_identity_cache.entries[i].identity_key); + free(g_identity_cache.entries[i].project_name); + } + free(g_identity_cache.entries); + memset(&g_identity_cache, 0, sizeof(g_identity_cache)); +} + +void cbm_project_identity_cache_invalidate(void) { + identity_cache_free(); +} + +static bool identity_cache_load(void) { + const char *cache_dir = cbm_resolve_cache_dir(); + if (g_identity_cache.loaded && + strcmp(g_identity_cache.cache_dir, cache_dir) == 0) { + return true; + } + + identity_cache_free(); + + cbm_dir_t *d = cbm_opendir(cache_dir); + if (!d) { + snprintf(g_identity_cache.cache_dir, sizeof(g_identity_cache.cache_dir), "%s", cache_dir); + g_identity_cache.loaded = true; + return true; + } + + cbm_dirent_t *entry; + while ((entry = cbm_readdir(d)) != NULL) { + const char *name = entry->name; + size_t len = strlen(name); + if (!is_project_db_file(name, len)) { + continue; + } + + char db_path[2048]; + snprintf(db_path, sizeof(db_path), "%s/%s", cache_dir, name); + + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (!store) { + continue; + } + + char project_name[1024]; + snprintf(project_name, sizeof(project_name), "%.*s", (int)(len - 3), name); + + cbm_project_t proj = {0}; + if (cbm_store_get_project(store, project_name, &proj) != CBM_STORE_OK || !proj.root_path) { + safe_str_free(&proj.name); + safe_str_free(&proj.indexed_at); + safe_str_free(&proj.root_path); + cbm_store_close(store); + continue; + } + + char indexed_key[4096]; + bool has_key = cbm_project_identity_key(proj.root_path, indexed_key, sizeof(indexed_key)); + + safe_str_free(&proj.name); + safe_str_free(&proj.indexed_at); + safe_str_free(&proj.root_path); + cbm_store_close(store); + + if (!has_key) { + continue; + } + + proj_identity_entry_t row = { + .identity_key = strdup(indexed_key), + .project_name = strdup(project_name), + }; + if (!row.identity_key || !row.project_name) { + free(row.identity_key); + free(row.project_name); + continue; + } + + proj_identity_entry_t *grown = + realloc(g_identity_cache.entries, + (g_identity_cache.count + 1) * sizeof(*g_identity_cache.entries)); + if (!grown) { + free(row.identity_key); + free(row.project_name); + continue; + } + g_identity_cache.entries = grown; + g_identity_cache.entries[g_identity_cache.count++] = row; + } + + cbm_closedir(d); + snprintf(g_identity_cache.cache_dir, sizeof(g_identity_cache.cache_dir), "%s", cache_dir); + g_identity_cache.loaded = true; + return true; +} + +char *cbm_find_existing_project_name(const char *repo_path) { + if (!repo_path || !repo_path[0]) { + return NULL; + } + + char query_key[4096]; + if (!cbm_project_identity_key(repo_path, query_key, sizeof(query_key))) { + return NULL; + } + + if (!identity_cache_load()) { + return NULL; + } + + char *best_name = NULL; + size_t best_root_len = 0; + + for (size_t i = 0; i < g_identity_cache.count; i++) { + const char *indexed_key = g_identity_cache.entries[i].identity_key; + const char *project_name = g_identity_cache.entries[i].project_name; + if (!indexed_key || !project_name) { + continue; + } + + if (!identity_is_child(query_key, indexed_key)) { + continue; + } + + size_t root_len = strlen(indexed_key); + if (!best_name || root_len > best_root_len) { + free(best_name); + best_name = strdup(project_name); + best_root_len = root_len; + } + } + + return best_name; +} diff --git a/src/pipeline/project_resolve.h b/src/pipeline/project_resolve.h new file mode 100644 index 00000000..3395487a --- /dev/null +++ b/src/pipeline/project_resolve.h @@ -0,0 +1,22 @@ +#ifndef CBM_PROJECT_RESOLVE_H +#define CBM_PROJECT_RESOLVE_H + +#include +#include + +/* Canonicalize path (realpath / _fullpath). Returns false if path is invalid. */ +bool cbm_path_canonicalize(const char *path, char *out, size_t out_sz); + +/* Stable per-worktree identity: canonicalized git worktree root when available, + * else canonical filesystem path. Distinct linked worktrees stay distinct. */ +bool cbm_project_identity_key(const char *repo_path, char *out, size_t out_sz); + +/* Return heap-allocated existing project name when repo_path matches a cached + * index (exact worktree identity or a subdirectory of an indexed root). + * Caller frees; NULL if no match. Uses a read-only scan cached until invalidate. */ +char *cbm_find_existing_project_name(const char *repo_path); + +/* Drop the cached identity scan (call after indexing completes). */ +void cbm_project_identity_cache_invalidate(void); + +#endif diff --git a/tests/test_project_resolve.c b/tests/test_project_resolve.c new file mode 100644 index 00000000..5c43c514 --- /dev/null +++ b/tests/test_project_resolve.c @@ -0,0 +1,188 @@ +/* + * test_project_resolve.c — Canonical project identity and duplicate-index prevention. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/project_resolve.h" +#include "pipeline/pipeline.h" +#include "git/git_context.h" +#include + +#include +#include +#include +#include + +static const char *saved_cache_dir; + +static void push_cache_dir(const char *cache) { + saved_cache_dir = getenv("CBM_CACHE_DIR"); + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_project_identity_cache_invalidate(); +} + +static void pop_cache_dir(void) { + if (saved_cache_dir) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_dir, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + cbm_project_identity_cache_invalidate(); + saved_cache_dir = NULL; +} + +static int seed_project_db(const char *cache, const char *project, const char *root) { + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return -1; + } + int rc = cbm_store_upsert_project(store, project, root); + cbm_store_close(store); + return rc == CBM_STORE_OK ? 0 : -1; +} + +static bool git_available(void) { +#ifdef _WIN32 + return system("git --version >NUL 2>&1") == 0; +#else + return system("git --version >/dev/null 2>&1") == 0; +#endif +} + +static int run_cmd(const char *cmd) { + return system(cmd); +} + +TEST(project_resolve_path_canonicalize) { + char *tmpdir = th_mktempdir("cbm-projres"); + if (!tmpdir) { + FAIL("th_mktempdir failed"); + } + + const char *file = TH_PATH(tmpdir, "readme.txt"); + ASSERT_EQ(th_write_file(file, "x"), 0); + + char canon[1024]; + ASSERT_TRUE(cbm_path_canonicalize(file, canon, sizeof(canon))); + ASSERT(strstr(canon, "readme.txt") != NULL); + + th_cleanup(tmpdir); + PASS(); +} + +TEST(project_resolve_identity_key_stable) { + char key1[1024]; + char key2[1024]; + ASSERT_TRUE(cbm_project_identity_key("/tmp/foo/bar", key1, sizeof(key1))); + ASSERT_TRUE(cbm_project_identity_key("/tmp/foo/bar/", key2, sizeof(key2))); + ASSERT_STR_EQ(key1, key2); + PASS(); +} + +TEST(project_resolve_find_existing_by_root_path) { + char *cache = th_mktempdir("cbm-projres-cache"); + if (!cache) { + FAIL("th_mktempdir failed"); + } + + const char *root = TH_PATH(cache, "repo-root"); + ASSERT_EQ(th_mkdir_p(root), 0); + + push_cache_dir(cache); + ASSERT_EQ(seed_project_db(cache, "indexed-project", root), 0); + + char *found = cbm_find_existing_project_name(root); + pop_cache_dir(); + + ASSERT_NOT_NULL(found); + ASSERT_STR_EQ(found, "indexed-project"); + free(found); + + th_cleanup(cache); + PASS(); +} + +TEST(project_resolve_pipeline_reuses_existing_name) { + char *cache = th_mktempdir("cbm-projres-pl"); + if (!cache) { + FAIL("th_mktempdir failed"); + } + + const char *root = TH_PATH(cache, "worktree"); + ASSERT_EQ(th_mkdir_p(root), 0); + + push_cache_dir(cache); + ASSERT_EQ(seed_project_db(cache, "canonical-name", root), 0); + + cbm_pipeline_t *p = cbm_pipeline_new(root, NULL, CBM_MODE_FAST); + pop_cache_dir(); + + ASSERT_NOT_NULL(p); + ASSERT_STR_EQ(cbm_pipeline_project_name(p), "canonical-name"); + cbm_pipeline_free(p); + + th_cleanup(cache); + PASS(); +} + +TEST(project_resolve_worktrees_distinct) { + if (!git_available()) { + FAIL("git unavailable"); + } + + char *tmp = th_mktempdir("cbm-projres-wt"); + if (!tmp) { + FAIL("th_mktempdir failed"); + } + + const char *repo = TH_PATH(tmp, "repo"); + const char *wt = TH_PATH(tmp, "wt"); + ASSERT_EQ(th_mkdir_p(repo), 0); + +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), "git -C \"%s\" init >%s 2>&1", repo, null_dev); + ASSERT_EQ(run_cmd(cmd), 0); + snprintf(cmd, sizeof(cmd), "git -C \"%s\" config user.email \"t@t.com\" >%s 2>&1", repo, null_dev); + ASSERT_EQ(run_cmd(cmd), 0); + snprintf(cmd, sizeof(cmd), "git -C \"%s\" config user.name \"t\" >%s 2>&1", repo, null_dev); + ASSERT_EQ(run_cmd(cmd), 0); + snprintf(cmd, sizeof(cmd), "git -C \"%s\" commit --allow-empty -m init >%s 2>&1", repo, null_dev); + ASSERT_EQ(run_cmd(cmd), 0); + snprintf(cmd, sizeof(cmd), "git -C \"%s\" worktree add -b feature/wt \"%s\" >%s 2>&1", repo, wt, + null_dev); + ASSERT_EQ(run_cmd(cmd), 0); + + cbm_git_context_t main_ctx = {0}; + cbm_git_context_t wt_ctx = {0}; + ASSERT_EQ(cbm_git_context_resolve(repo, &main_ctx), 0); + ASSERT_EQ(cbm_git_context_resolve(wt, &wt_ctx), 0); + ASSERT_STR_EQ(main_ctx.canonical_root, wt_ctx.canonical_root); + + char main_key[4096]; + char wt_key[4096]; + ASSERT_TRUE(cbm_project_identity_key(repo, main_key, sizeof(main_key))); + ASSERT_TRUE(cbm_project_identity_key(wt, wt_key, sizeof(wt_key))); + ASSERT_STR_NEQ(main_key, wt_key); + + cbm_git_context_free(&main_ctx); + cbm_git_context_free(&wt_ctx); + th_cleanup(tmp); + PASS(); +} + +SUITE(project_resolve) { + RUN_TEST(project_resolve_path_canonicalize); + RUN_TEST(project_resolve_identity_key_stable); + RUN_TEST(project_resolve_find_existing_by_root_path); + RUN_TEST(project_resolve_pipeline_reuses_existing_name); + RUN_TEST(project_resolve_worktrees_distinct); +}