Skip to content

Commit 9376ca1

Browse files
committed
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 <canada11@duck.com>
1 parent 54efcef commit 9376ca1

6 files changed

Lines changed: 454 additions & 6 deletions

File tree

Makefile.cbm

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ GRAPH_BUFFER_SRCS = src/graph_buffer/graph_buffer.c
211211
# Pipeline module (new)
212212
PIPELINE_SRCS = \
213213
src/pipeline/fqn.c \
214+
src/pipeline/project_resolve.c \
214215
src/pipeline/path_alias.c \
215216
src/pipeline/registry.c \
216217
src/pipeline/pipeline.c \
@@ -370,7 +371,7 @@ TEST_DISCOVER_SRCS = \
370371

371372
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
372373

373-
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
374+
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
374375

375376
TEST_WATCHER_SRCS = tests/test_watcher.c
376377

src/mcp/mcp.c

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ enum {
4343
#include <sqlite3.h>
4444
#include "cypher/cypher.h"
4545
#include "pipeline/pipeline.h"
46+
#include "pipeline/project_resolve.h"
4647
#include "pipeline/pass_cross_repo.h"
4748
#include "git/git_context.h"
4849
#include "cli/cli.h"
@@ -5624,10 +5625,17 @@ static void detect_session(cbm_mcp_server_t *srv) {
56245625
* used by the pipeline, otherwise session queries look for a .db file
56255626
* that doesn't match the indexed project name. */
56265627
if (srv->session_root[0]) {
5627-
char *pname = cbm_project_name_from_path(srv->session_root);
5628-
if (pname) {
5629-
snprintf(srv->session_project, sizeof(srv->session_project), "%s", pname);
5630-
free(pname);
5628+
char *existing = cbm_find_existing_project_name(srv->session_root);
5629+
if (existing) {
5630+
snprintf(srv->session_project, sizeof(srv->session_project), "%s", existing);
5631+
cbm_log_info("session.project.reuse", "project", existing, "path", srv->session_root);
5632+
free(existing);
5633+
} else {
5634+
char *pname = cbm_project_name_from_path(srv->session_root);
5635+
if (pname) {
5636+
snprintf(srv->session_project, sizeof(srv->session_project), "%s", pname);
5637+
free(pname);
5638+
}
56315639
}
56325640
}
56335641
}

src/pipeline/pipeline.c

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, PL_WAL_BUF = 1040 };
1616
#define PL_NSEC_PER_SEC 1000000000LL
1717
#include "pipeline/pipeline.h"
18+
#include "pipeline/project_resolve.h"
1819
#include "pipeline/artifact.h"
1920
#include "pipeline/pipeline_internal.h"
2021
#include "pipeline/pass_lsp_cross.h"
@@ -166,7 +167,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path,
166167

167168
p->repo_path = strdup(repo_path);
168169
p->db_path = db_path ? strdup(db_path) : NULL;
169-
p->project_name = cbm_project_name_from_path(repo_path);
170+
char *existing = cbm_find_existing_project_name(repo_path);
171+
p->project_name = existing ? existing : cbm_project_name_from_path(repo_path);
170172
(void)cbm_git_context_resolve(repo_path, &p->git_ctx);
171173
p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx);
172174
p->mode = mode;
@@ -1265,6 +1267,9 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
12651267
rc = try_incremental_or_delete_db(p, files, file_count);
12661268
if (rc >= 0) {
12671269
cbm_discover_free(files, file_count);
1270+
if (rc == 0) {
1271+
cbm_project_identity_cache_invalidate();
1272+
}
12681273
return rc;
12691274
}
12701275
cbm_log_info("pipeline.route", "path", "full");
@@ -1320,5 +1325,8 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
13201325
cbm_set_user_lang_config(NULL);
13211326
cbm_userconfig_free(p->userconfig);
13221327
p->userconfig = NULL;
1328+
if (rc == 0) {
1329+
cbm_project_identity_cache_invalidate();
1330+
}
13231331
return rc;
13241332
}

src/pipeline/project_resolve.c

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* project_resolve.c — Canonical path identity and duplicate-index prevention.
3+
*/
4+
#include "pipeline/project_resolve.h"
5+
#include "foundation/platform.h"
6+
#include "foundation/compat_fs.h"
7+
#include "foundation/str_util.h"
8+
#include "git/git_context.h"
9+
#include "store/store.h"
10+
11+
#include <stdio.h>
12+
#include <stdlib.h>
13+
#include <string.h>
14+
15+
typedef struct {
16+
char *identity_key;
17+
char *project_name;
18+
} proj_identity_entry_t;
19+
20+
typedef struct {
21+
bool loaded;
22+
char cache_dir[1024];
23+
proj_identity_entry_t *entries;
24+
size_t count;
25+
} proj_identity_cache_t;
26+
27+
static proj_identity_cache_t g_identity_cache;
28+
29+
bool cbm_path_canonicalize(const char *path, char *out, size_t out_sz) {
30+
if (!path || !out || out_sz == 0) {
31+
return false;
32+
}
33+
out[0] = '\0';
34+
#ifdef _WIN32
35+
if (!_fullpath(out, path, out_sz)) {
36+
return false;
37+
}
38+
cbm_normalize_path_sep(out);
39+
#else
40+
if (!realpath(path, real)) {
41+
return false;
42+
}
43+
#endif
44+
return out[0] != '\0';
45+
}
46+
47+
bool cbm_project_identity_key(const char *repo_path, char *out, size_t out_sz) {
48+
if (!repo_path || !out || out_sz == 0) {
49+
return false;
50+
}
51+
52+
cbm_git_context_t ctx = {0};
53+
if (cbm_git_context_resolve(repo_path, &ctx) == 0 && ctx.is_git && ctx.worktree_root &&
54+
ctx.worktree_root[0]) {
55+
bool ok = cbm_path_canonicalize(ctx.worktree_root, out, out_sz);
56+
cbm_git_context_free(&ctx);
57+
return ok;
58+
}
59+
cbm_git_context_free(&ctx);
60+
return cbm_path_canonicalize(repo_path, out, out_sz);
61+
}
62+
63+
static bool identity_is_child(const char *child, const char *parent) {
64+
if (!child[0] || !parent[0]) {
65+
return false;
66+
}
67+
if (strcmp(child, parent) == 0) {
68+
return true;
69+
}
70+
size_t plen = strlen(parent);
71+
if (strncmp(child, parent, plen) != 0) {
72+
return false;
73+
}
74+
return child[plen] == '/';
75+
}
76+
77+
static bool is_project_db_file(const char *name, size_t len) {
78+
if (len < 5 || strcmp(name + len - 3, ".db") != 0) {
79+
return false;
80+
}
81+
if (name[0] == '_') {
82+
return false;
83+
}
84+
return true;
85+
}
86+
87+
static void identity_cache_free(void) {
88+
for (size_t i = 0; i < g_identity_cache.count; i++) {
89+
free(g_identity_cache.entries[i].identity_key);
90+
free(g_identity_cache.entries[i].project_name);
91+
}
92+
free(g_identity_cache.entries);
93+
memset(&g_identity_cache, 0, sizeof(g_identity_cache));
94+
}
95+
96+
void cbm_project_identity_cache_invalidate(void) {
97+
identity_cache_free();
98+
}
99+
100+
static bool identity_cache_load(void) {
101+
const char *cache_dir = cbm_resolve_cache_dir();
102+
if (g_identity_cache.loaded &&
103+
strcmp(g_identity_cache.cache_dir, cache_dir) == 0) {
104+
return true;
105+
}
106+
107+
identity_cache_free();
108+
109+
cbm_dir_t *d = cbm_opendir(cache_dir);
110+
if (!d) {
111+
snprintf(g_identity_cache.cache_dir, sizeof(g_identity_cache.cache_dir), "%s", cache_dir);
112+
g_identity_cache.loaded = true;
113+
return true;
114+
}
115+
116+
cbm_dirent_t *entry;
117+
while ((entry = cbm_readdir(d)) != NULL) {
118+
const char *name = entry->name;
119+
size_t len = strlen(name);
120+
if (!is_project_db_file(name, len)) {
121+
continue;
122+
}
123+
124+
char db_path[2048];
125+
snprintf(db_path, sizeof(db_path), "%s/%s", cache_dir, name);
126+
127+
cbm_store_t *store = cbm_store_open_path_query(db_path);
128+
if (!store) {
129+
continue;
130+
}
131+
132+
char project_name[1024];
133+
snprintf(project_name, sizeof(project_name), "%.*s", (int)(len - 3), name);
134+
135+
cbm_project_t proj = {0};
136+
if (cbm_store_get_project(store, project_name, &proj) != CBM_STORE_OK || !proj.root_path) {
137+
safe_str_free(&proj.name);
138+
safe_str_free(&proj.indexed_at);
139+
safe_str_free(&proj.root_path);
140+
cbm_store_close(store);
141+
continue;
142+
}
143+
144+
char indexed_key[4096];
145+
bool has_key = cbm_project_identity_key(proj.root_path, indexed_key, sizeof(indexed_key));
146+
147+
safe_str_free(&proj.name);
148+
safe_str_free(&proj.indexed_at);
149+
safe_str_free(&proj.root_path);
150+
cbm_store_close(store);
151+
152+
if (!has_key) {
153+
continue;
154+
}
155+
156+
proj_identity_entry_t row = {
157+
.identity_key = strdup(indexed_key),
158+
.project_name = strdup(project_name),
159+
};
160+
if (!row.identity_key || !row.project_name) {
161+
free(row.identity_key);
162+
free(row.project_name);
163+
continue;
164+
}
165+
166+
proj_identity_entry_t *grown =
167+
realloc(g_identity_cache.entries,
168+
(g_identity_cache.count + 1) * sizeof(*g_identity_cache.entries));
169+
if (!grown) {
170+
free(row.identity_key);
171+
free(row.project_name);
172+
continue;
173+
}
174+
g_identity_cache.entries = grown;
175+
g_identity_cache.entries[g_identity_cache.count++] = row;
176+
}
177+
178+
cbm_closedir(d);
179+
snprintf(g_identity_cache.cache_dir, sizeof(g_identity_cache.cache_dir), "%s", cache_dir);
180+
g_identity_cache.loaded = true;
181+
return true;
182+
}
183+
184+
char *cbm_find_existing_project_name(const char *repo_path) {
185+
if (!repo_path || !repo_path[0]) {
186+
return NULL;
187+
}
188+
189+
char query_key[4096];
190+
if (!cbm_project_identity_key(repo_path, query_key, sizeof(query_key))) {
191+
return NULL;
192+
}
193+
194+
if (!identity_cache_load()) {
195+
return NULL;
196+
}
197+
198+
char *best_name = NULL;
199+
size_t best_root_len = 0;
200+
201+
for (size_t i = 0; i < g_identity_cache.count; i++) {
202+
const char *indexed_key = g_identity_cache.entries[i].identity_key;
203+
const char *project_name = g_identity_cache.entries[i].project_name;
204+
if (!indexed_key || !project_name) {
205+
continue;
206+
}
207+
208+
if (!identity_is_child(query_key, indexed_key)) {
209+
continue;
210+
}
211+
212+
size_t root_len = strlen(indexed_key);
213+
if (!best_name || root_len > best_root_len) {
214+
free(best_name);
215+
best_name = strdup(project_name);
216+
best_root_len = root_len;
217+
}
218+
}
219+
220+
return best_name;
221+
}

src/pipeline/project_resolve.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#ifndef CBM_PROJECT_RESOLVE_H
2+
#define CBM_PROJECT_RESOLVE_H
3+
4+
#include <stdbool.h>
5+
#include <stddef.h>
6+
7+
/* Canonicalize path (realpath / _fullpath). Returns false if path is invalid. */
8+
bool cbm_path_canonicalize(const char *path, char *out, size_t out_sz);
9+
10+
/* Stable per-worktree identity: canonicalized git worktree root when available,
11+
* else canonical filesystem path. Distinct linked worktrees stay distinct. */
12+
bool cbm_project_identity_key(const char *repo_path, char *out, size_t out_sz);
13+
14+
/* Return heap-allocated existing project name when repo_path matches a cached
15+
* index (exact worktree identity or a subdirectory of an indexed root).
16+
* Caller frees; NULL if no match. Uses a read-only scan cached until invalidate. */
17+
char *cbm_find_existing_project_name(const char *repo_path);
18+
19+
/* Drop the cached identity scan (call after indexing completes). */
20+
void cbm_project_identity_cache_invalidate(void);
21+
22+
#endif

0 commit comments

Comments
 (0)