From 26b5d96cd85f3b732adf08e05353902655e3229b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 07:57:05 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(mcp):=20get=5Fnode=5Fhistory=20?= =?UTF-8?q?=E2=80=94=20per-node=20git=20timeline,=20lazy=20+=20sparse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP/CLI tool: the evolution timeline of any line-scoped graph node (function/class/method) — every commit that touched its line range, newest first, with author/date/subject and per-commit line stats. Design: sparse versioning with on-demand query power. - Metadata only in SQLite (node_revisions + node_revision_meta, created lazily on first use so query-path opens and pre-existing DBs upgrade in place). Patch text is never stored: git is already a content-addressed store of every version, so diffs are regenerated on demand via include_patch. - Lazy compute + cache: first query runs git log -L (~50ms) and caches until HEAD moves; warm queries serve from SQLite (~25ms). - Working-tree drift mapping: indexed line numbers are remapped to HEAD coordinates by parsing git diff -U0 hunk headers, so uncommitted edits above a node no longer shift its history onto the wrong lines. Dirty files bypass the cache and are flagged computed_dirty. - co_changed option surfaces temporal coupling: files that repeatedly changed in the same commits as the symbol, which static edges miss. Benchmarked on this repo (6 provenance/coupling/volatility questions, A/B vs a raw-git baseline agent): answer parity after the drift fix, with single get_node_history calls replacing git-log + awk pipelines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019w17HvR6LS8wk1DPDDdUHH --- src/mcp/mcp.c | 590 ++++++++++++++++++++++++++++++++++++++++++++++ src/store/store.c | 200 ++++++++++++++++ src/store/store.h | 31 +++ 3 files changed, 821 insertions(+) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 26b232d20..05dd9a94e 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -433,6 +433,30 @@ static const tool_def_t TOOLS[] = { "\"Git ref or date to compare from (e.g. HEAD~5, v0.5.0, 2026-01-01)\"}},\"required\":" "[\"project\"]}"}, + {"get_node_history", + "Get the git evolution timeline of a symbol (function/class/method): every commit that " + "touched its line range, newest first, with author, date, subject and per-commit line " + "stats. Use INSTEAD OF raw git log when you need provenance ('why does this code look " + "like this', 'when was this guard added', 'has this been reverted before') — the timeline " + "is scoped to the symbol, not the whole file, so it fits in context. Computed lazily via " + "git line-range tracking on first call, then cached in the graph DB until HEAD moves. " + "Options: include_patch returns the actual diffs of the most recent revisions (regenerated " + "from git on demand, never stored); co_changed returns files that most often changed in " + "the same commits as this symbol — hidden coupling that static edges miss. " + "IMPORTANT: First call search_graph to find the exact qualified_name, then pass it here.", + "{\"type\":\"object\",\"properties\":{\"qualified_name\":{\"type\":\"string\"," + "\"description\":\"Full qualified_name from search_graph, or short symbol name " + "(suggestions returned if ambiguous)\"},\"project\":{\"type\":\"string\"}," + "\"limit\":{\"type\":\"integer\",\"default\":20,\"description\":\"Max revisions in the " + "response (full count always reported in total_revisions)\"}," + "\"include_patch\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Embed the " + "diff of the newest patch_limit revisions\"}," + "\"patch_limit\":{\"type\":\"integer\",\"default\":3,\"description\":\"How many revision " + "diffs to embed when include_patch is true\"}," + "\"co_changed\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Also return " + "files that repeatedly changed in the same commits as this symbol (temporal coupling)\"}}," + "\"required\":[\"qualified_name\",\"project\"]}"}, + {"manage_adr", "Create or update Architecture Decision Records", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":" "\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"content\":{\"type\":\"string\"}," @@ -4165,6 +4189,569 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { return result; } +/* ── get_node_history ─────────────────────────────────────────── */ + +enum { + NH_DEFAULT_LIMIT = 20, + NH_DEFAULT_PATCH_LIMIT = 3, + NH_PATCH_CAP = 8192, /* max embedded patch bytes per revision */ + NH_MAX_REVS = 512, /* hard cap on parsed revisions */ + NH_COCHANGE_SHAS = 12, /* commits sampled for co-change */ + NH_COCHANGE_FILES = 64, /* distinct co-changed files tracked */ + NH_COCHANGE_TOP = 10, /* co-changed files returned */ + NH_SHA_BUF = 48, + NH_DATE_BUF = 24, + NH_US = 0x1f, /* ASCII unit separator used in the git format string */ +}; + +/* One parsed revision. patch is heap-allocated only when capturing diffs. */ +typedef struct { + char sha[NH_SHA_BUF]; + int64_t ts; + char author[CBM_SZ_256]; + char subject[CBM_SZ_512]; + int added; + int deleted; + char *patch; + bool patch_truncated; +} nh_rev_t; + +/* Split a "@@C@@\x1f\x1f\x1f" line into rev. */ +static bool nh_parse_commit_line(const char *line, nh_rev_t *rev) { + memset(rev, 0, sizeof(*rev)); + const char *p = line + SLEN("@@C@@"); + const char *sep1 = strchr(p, NH_US); + if (!sep1) { + return false; + } + size_t sha_len = (size_t)(sep1 - p); + if (sha_len >= sizeof(rev->sha)) { + sha_len = sizeof(rev->sha) - SKIP_ONE; + } + memcpy(rev->sha, p, sha_len); + rev->sha[sha_len] = '\0'; + + const char *sep2 = strchr(sep1 + SKIP_ONE, NH_US); + if (!sep2) { + return false; + } + rev->ts = strtoll(sep1 + SKIP_ONE, NULL, MCP_COL_10); + + const char *sep3 = strchr(sep2 + SKIP_ONE, NH_US); + if (!sep3) { + return false; + } + size_t alen = (size_t)(sep3 - (sep2 + SKIP_ONE)); + if (alen >= sizeof(rev->author)) { + alen = sizeof(rev->author) - SKIP_ONE; + } + memcpy(rev->author, sep2 + SKIP_ONE, alen); + rev->author[alen] = '\0'; + + snprintf(rev->subject, sizeof(rev->subject), "%s", sep3 + SKIP_ONE); + size_t slen = strlen(rev->subject); + while (slen > 0 && + (rev->subject[slen - SKIP_ONE] == '\n' || rev->subject[slen - SKIP_ONE] == '\r')) { + rev->subject[--slen] = '\0'; + } + return true; +} + +/* Append a line to rev->patch, growing it up to NH_PATCH_CAP. */ +static void nh_append_patch(nh_rev_t *rev, const char *line) { + size_t cur = rev->patch ? strlen(rev->patch) : 0; + size_t add = strlen(line); + if (cur + add >= NH_PATCH_CAP) { + rev->patch_truncated = true; + return; + } + char *grown = realloc(rev->patch, cur + add + SKIP_ONE); + if (!grown) { + return; + } + if (cur == 0) { + grown[0] = '\0'; + } + rev->patch = grown; + memcpy(rev->patch + cur, line, add + SKIP_ONE); +} + +/* Run `git log -L,:` and parse the commit stream. + * want_patch: capture diff text for the first patch_limit revisions. + * Returns revision count, or -1 when git failed and nothing was parsed. */ +static int nh_run_git_log(const char *root_path, const char *file_path, int start_line, + int end_line, bool want_patch, int patch_limit, nh_rev_t **out) { + char cmd[CBM_SZ_2K]; +#ifdef _WIN32 + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" log \"-L%d,%d:%s\" " + "\"--format=@@C@@%%H%%x1f%%at%%x1f%%an%%x1f%%s\" 2>NUL", + root_path, start_line, end_line, file_path); +#else + snprintf(cmd, sizeof(cmd), + "git -C '%s' log '-L%d,%d:%s' " + "'--format=@@C@@%%H%%x1f%%at%%x1f%%an%%x1f%%s' 2>/dev/null", + root_path, start_line, end_line, file_path); +#endif + + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + + nh_rev_t *revs = calloc(NH_MAX_REVS, sizeof(nh_rev_t)); + if (!revs) { + cbm_pclose(fp); + return -1; + } + int n = 0; + nh_rev_t *cur = NULL; + char line[CBM_SZ_4K]; + + while (fgets(line, sizeof(line), fp)) { + if (strncmp(line, "@@C@@", SLEN("@@C@@")) == 0) { + if (n >= NH_MAX_REVS) { + break; + } + if (nh_parse_commit_line(line, &revs[n])) { + cur = &revs[n]; + n++; + } else { + cur = NULL; + } + continue; + } + if (!cur) { + continue; + } + /* Diff body: count range-scoped adds/deletes, skipping headers. */ + if (line[0] == '+' && strncmp(line, "+++", SLEN("+++")) != 0) { + cur->added++; + } else if (line[0] == '-' && strncmp(line, "---", SLEN("---")) != 0) { + cur->deleted++; + } + if (want_patch && (cur - revs) < patch_limit) { + nh_append_patch(cur, line); + } + } + int status = cbm_pclose(fp); + if (n == 0 && status != 0) { + free(revs); + return -1; + } + *out = revs; + return n; +} + +static void nh_free_revs(nh_rev_t *revs, int count) { + if (!revs) { + return; + } + for (int i = 0; i < count; i++) { + free(revs[i].patch); + } + free(revs); +} + +/* Map a working-tree line range to HEAD coordinates. + * + * The index stores line numbers of the file as it sits on disk, but + * `git log -L` anchors the range at HEAD — so uncommitted edits above a + * node silently shift every later node's history onto the wrong lines + * (the drift problem Sourcegraph solves with nearest-snapshot diff + * adjustment). Parse `git diff -U0 HEAD -- ` hunk headers and + * subtract the cumulative new-minus-old delta of hunks above the range. + * + * Sets *modified when the file differs from HEAD at all (cache must be + * bypassed: the mapping changes with every edit, not with HEAD), and + * *overlap when a hunk intersects the range itself (the node's own body + * has uncommitted edits — history is valid only up to HEAD). */ +static void nh_map_range_to_head(const char *root_path, const char *file_path, int *start, int *end, + bool *modified, bool *overlap) { + *modified = false; + *overlap = false; + + char cmd[CBM_SZ_2K]; +#ifdef _WIN32 + snprintf(cmd, sizeof(cmd), "git -C \"%s\" diff -U0 HEAD -- \"%s\" 2>NUL", root_path, file_path); +#else + snprintf(cmd, sizeof(cmd), "git -C '%s' diff -U0 HEAD -- '%s' 2>/dev/null", root_path, + file_path); +#endif + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return; + } + + int delta_start = 0; + int delta_end = 0; + char line[CBM_SZ_1K]; + while (fgets(line, sizeof(line), fp)) { + if (strncmp(line, "@@ -", SLEN("@@ -")) != 0) { + continue; + } + *modified = true; + int old_len = SKIP_ONE; + int new_start = 0; + int new_len = SKIP_ONE; + const char *p = line + SLEN("@@ -"); + (void)strtol(p, (char **)&p, MCP_COL_10); + if (*p == ',') { + old_len = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); + } + p = strchr(p, '+'); + if (!p) { + continue; + } + new_start = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); + if (*p == ',') { + new_len = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); + } + + /* New-side extent of this hunk (pure deletions occupy no lines). */ + int hunk_new_end = new_len > 0 ? new_start + new_len - SKIP_ONE : new_start; + int shift = new_len - old_len; + + if (hunk_new_end < *start) { + delta_start += shift; + delta_end += shift; + } else if (new_start <= *end) { + *overlap = true; + delta_end += shift; /* approximate: keep the range covering */ + } + /* hunks fully below the range: no effect */ + } + cbm_pclose(fp); + + if (*modified) { + *start -= delta_start; + *end -= delta_end; + if (*start < SKIP_ONE) { + *start = SKIP_ONE; + } + if (*end < *start) { + *end = *start; + } + } +} + +/* Read the repo's current HEAD sha into out (empty string on failure). */ +static void nh_git_head(const char *root_path, char *out, size_t cap) { + out[0] = '\0'; + char cmd[CBM_SZ_1K]; +#ifdef _WIN32 + snprintf(cmd, sizeof(cmd), "git -C \"%s\" rev-parse HEAD 2>NUL", root_path); +#else + snprintf(cmd, sizeof(cmd), "git -C '%s' rev-parse HEAD 2>/dev/null", root_path); +#endif + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return; + } + if (fgets(out, (int)cap, fp)) { + size_t len = strlen(out); + while (len > 0 && (out[len - SKIP_ONE] == '\n' || out[len - SKIP_ONE] == '\r')) { + out[--len] = '\0'; + } + } + cbm_pclose(fp); +} + +/* Temporal coupling: count files co-occurring with node_file across the + * node's commits. Emits the top files as {file, co_changes} into arr. */ +static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const char *root_path, + const char *node_file, const cbm_node_revision_t *revs, int count) { + int nsha = count < NH_COCHANGE_SHAS ? count : NH_COCHANGE_SHAS; + if (nsha == 0) { + return; + } + + char cmd[CBM_SZ_4K]; + /* Format must contain a '%' placeholder or git treats it as a pretty + * preset name and dies with "invalid --pretty format". */ +#ifdef _WIN32 + const char *prefix_fmt = "git -C \"%s\" show --name-only \"--format=@@C@@%%H\" "; + const char *devnull = "2>NUL"; +#else + const char *prefix_fmt = "git -C '%s' show --name-only '--format=@@C@@%%H' "; + const char *devnull = "2>/dev/null"; +#endif + int off = snprintf(cmd, sizeof(cmd), prefix_fmt, root_path); + for (int i = 0; i < nsha && off < (int)sizeof(cmd) - NH_SHA_BUF - CBM_SZ_16; i++) { + off += snprintf(cmd + off, sizeof(cmd) - off, "%s ", revs[i].sha); + } + snprintf(cmd + off, sizeof(cmd) - off, "%s", devnull); + + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return; + } + + char files[NH_COCHANGE_FILES][CBM_SZ_512]; + int counts[NH_COCHANGE_FILES] = {0}; + int nfiles = 0; + + char line[CBM_SZ_1K]; + while (fgets(line, sizeof(line), fp)) { + size_t len = strlen(line); + while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) { + line[--len] = '\0'; + } + if (len == 0 || strncmp(line, "@@C@@", SLEN("@@C@@")) == 0) { + continue; + } + if (strcmp(line, node_file) == 0) { + continue; + } + int idx = -1; + for (int i = 0; i < nfiles; i++) { + if (strcmp(files[i], line) == 0) { + idx = i; + break; + } + } + if (idx < 0 && nfiles < NH_COCHANGE_FILES) { + idx = nfiles++; + snprintf(files[idx], sizeof(files[idx]), "%s", line); + } + if (idx >= 0) { + counts[idx]++; + } + } + cbm_pclose(fp); + + /* Emit the top entries by count (selection over a small fixed array). */ + for (int rank = 0; rank < NH_COCHANGE_TOP; rank++) { + int best = -1; + for (int i = 0; i < nfiles; i++) { + if (counts[i] > 0 && (best < 0 || counts[i] > counts[best])) { + best = i; + } + } + if (best < 0 || counts[best] < MCP_RETURN_2) { + break; /* require >=2 co-occurrences to call it coupling */ + } + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "file", files[best]); + yyjson_mut_obj_add_int(doc, item, "co_changes", counts[best]); + yyjson_mut_arr_add_val(arr, item); + counts[best] = 0; + } +} + +static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { + char *qn = cbm_mcp_get_string_arg(args, "qualified_name"); + char *project = cbm_mcp_get_string_arg(args, "project"); + int limit = cbm_mcp_get_int_arg(args, "limit", NH_DEFAULT_LIMIT); + bool include_patch = cbm_mcp_get_bool_arg(args, "include_patch"); + int patch_limit = cbm_mcp_get_int_arg(args, "patch_limit", NH_DEFAULT_PATCH_LIMIT); + bool co_changed = cbm_mcp_get_bool_arg(args, "co_changed"); + + if (!qn) { + free(project); + return cbm_mcp_text_result("qualified_name is required", true); + } + + cbm_store_t *store = resolve_store(srv, project); + if (!store) { + char *err = build_project_list_error("project not found or not indexed"); + char *res = cbm_mcp_text_result(err, true); + free(err); + free(qn); + free(project); + return res; + } + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(qn); + free(project); + return not_indexed; + } + const char *effective_project = project ? project : srv->current_project; + + /* Resolve the node: exact QN, then suffix (same tiers as get_code_snippet). */ + cbm_node_t node = {0}; + bool resolved = false; + if (cbm_store_find_node_by_qn(store, effective_project, qn, &node) == CBM_STORE_OK) { + resolved = true; + } else { + cbm_node_t *suffix_nodes = NULL; + int suffix_count = 0; + cbm_store_find_nodes_by_qn_suffix(store, effective_project, qn, &suffix_nodes, + &suffix_count); + if (suffix_count == SKIP_ONE) { + copy_node(&suffix_nodes[0], &node); + resolved = true; + } else if (suffix_count > SKIP_ONE) { + char *result = snippet_suggestions(qn, suffix_nodes, suffix_count); + cbm_store_free_nodes(suffix_nodes, suffix_count); + free(qn); + free(project); + return result; + } + cbm_store_free_nodes(suffix_nodes, suffix_count); + } + + if (!resolved) { + free(qn); + free(project); + return cbm_mcp_text_result("symbol not found. Use search_graph(name_pattern=\"...\") " + "first to discover the exact qualified_name.", + true); + } + + if (!node.file_path || node.file_path[0] == '\0' || node.start_line <= 0 || + node.end_line < node.start_line) { + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result("node has no line range — history is only available for " + "line-scoped symbols (functions, classes, methods)", + true); + } + + char *root_path = get_project_root(srv, effective_project); + if (!root_path || !validate_search_path_arg(root_path) || + !validate_search_path_arg(node.file_path)) { + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result("project root or file path unavailable/invalid", true); + } + + char head[NH_SHA_BUF]; + nh_git_head(root_path, head, sizeof(head)); + if (head[0] == '\0') { + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result("not a git repository (or git not installed)", true); + } + + /* Uncommitted edits shift indexed line numbers away from HEAD — map + * the range back to HEAD coordinates before tracking. */ + int track_start = node.start_line; + int track_end = node.end_line; + bool wt_modified = false; + bool wt_overlap = false; + nh_map_range_to_head(root_path, node.file_path, &track_start, &track_end, &wt_modified, + &wt_overlap); + + /* Cache check: valid while the repo HEAD hasn't moved AND the file is + * clean (a dirty file remaps on every edit, so bypass the cache). + * Patch text is never cached (sparse storage) — include_patch always + * re-runs git. */ + char cached_head[NH_SHA_BUF]; + cbm_store_get_node_revision_head(store, effective_project, node.qualified_name, cached_head, + sizeof(cached_head)); + bool cache_hit = !wt_modified && cached_head[0] != '\0' && strcmp(cached_head, head) == 0; + + nh_rev_t *fresh = NULL; + int fresh_count = 0; + if (!cache_hit || include_patch) { + fresh_count = nh_run_git_log(root_path, node.file_path, track_start, track_end, + include_patch, patch_limit, &fresh); + if (fresh_count < 0) { + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result( + "git log failed for this range — the index may be stale (file changed since " + "last index). Re-run index_repository and retry.", + true); + } + /* Persist metadata (sparse: no patch text). */ + cbm_node_revision_t *rows = calloc(fresh_count ? fresh_count : SKIP_ONE, sizeof(*rows)); + if (rows) { + for (int i = 0; i < fresh_count; i++) { + rows[i].sha = fresh[i].sha; + rows[i].ts = fresh[i].ts; + rows[i].author = fresh[i].author; + rows[i].subject = fresh[i].subject; + rows[i].added = fresh[i].added; + rows[i].deleted = fresh[i].deleted; + } + cbm_store_replace_node_revisions(store, effective_project, node.qualified_name, rows, + fresh_count, head); + free(rows); + } + } + + /* Serve from the cache table so both paths share one shape. */ + cbm_node_revision_t *revs = NULL; + int rev_count = 0; + cbm_store_get_node_revisions(store, effective_project, node.qualified_name, &revs, &rev_count); + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root_obj = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root_obj); + yyjson_mut_obj_add_strcpy(doc, root_obj, "qualified_name", node.qualified_name); + yyjson_mut_obj_add_strcpy(doc, root_obj, "file", node.file_path); + yyjson_mut_obj_add_int(doc, root_obj, "start_line", node.start_line); + yyjson_mut_obj_add_int(doc, root_obj, "end_line", node.end_line); + yyjson_mut_obj_add_strcpy(doc, root_obj, "cache", + cache_hit ? "hit" : (wt_modified ? "computed_dirty" : "computed")); + if (wt_modified) { + /* Uncommitted edits: indexed lines were remapped to HEAD coords. */ + yyjson_mut_obj_add_int(doc, root_obj, "tracked_start_at_head", track_start); + yyjson_mut_obj_add_int(doc, root_obj, "tracked_end_at_head", track_end); + } + if (wt_overlap) { + yyjson_mut_obj_add_strcpy(doc, root_obj, "note", + "this symbol has uncommitted edits — history reflects HEAD; " + "the working-tree change is not a commit yet"); + } + yyjson_mut_obj_add_int(doc, root_obj, "total_revisions", rev_count); + + yyjson_mut_val *arr = yyjson_mut_arr(doc); + int emit = rev_count < limit ? rev_count : limit; + for (int i = 0; i < emit; i++) { + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "sha", revs[i].sha); + char date[NH_DATE_BUF] = ""; + time_t tt = (time_t)revs[i].ts; + struct tm tmv; + if (cbm_gmtime_r(&tt, &tmv) != NULL) { + strftime(date, sizeof(date), "%Y-%m-%d", &tmv); + } + yyjson_mut_obj_add_strcpy(doc, item, "date", date); + yyjson_mut_obj_add_strcpy(doc, item, "author", revs[i].author); + yyjson_mut_obj_add_strcpy(doc, item, "subject", revs[i].subject); + yyjson_mut_obj_add_int(doc, item, "added", revs[i].added); + yyjson_mut_obj_add_int(doc, item, "deleted", revs[i].deleted); + if (include_patch && fresh && i < fresh_count && i < patch_limit && fresh[i].patch) { + yyjson_mut_obj_add_strcpy(doc, item, "patch", fresh[i].patch); + if (fresh[i].patch_truncated) { + yyjson_mut_obj_add_bool(doc, item, "patch_truncated", true); + } + } + yyjson_mut_arr_add_val(arr, item); + } + yyjson_mut_obj_add_val(doc, root_obj, "revisions", arr); + + if (co_changed) { + yyjson_mut_val *cc = yyjson_mut_arr(doc); + nh_add_co_changed(doc, cc, root_path, node.file_path, revs, rev_count); + yyjson_mut_obj_add_val(doc, root_obj, "co_changed_files", cc); + } + + cbm_store_free_node_revisions(revs, rev_count); + nh_free_revs(fresh, fresh_count); + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + char *result = cbm_mcp_text_result(json, false); + free(json); + return result; +} + /* ── Tool dispatch ────────────────────────────────────────────── */ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { @@ -4210,6 +4797,9 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch if (strcmp(tool_name, "detect_changes") == 0) { return handle_detect_changes(srv, args_json); } + if (strcmp(tool_name, "get_node_history") == 0) { + return handle_get_node_history(srv, args_json); + } if (strcmp(tool_name, "manage_adr") == 0) { return handle_manage_adr(srv, args_json); } diff --git a/src/store/store.c b/src/store/store.c index 1e7624514..42a3558d5 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -133,6 +133,13 @@ struct cbm_store { sqlite3_stmt *stmt_get_file_hashes; sqlite3_stmt *stmt_delete_file_hash; sqlite3_stmt *stmt_delete_file_hashes; + + sqlite3_stmt *stmt_ins_node_rev; + sqlite3_stmt *stmt_del_node_revs; + sqlite3_stmt *stmt_get_node_revs; + sqlite3_stmt *stmt_get_rev_meta; + sqlite3_stmt *stmt_upsert_rev_meta; + bool node_rev_schema_ready; /* lazy DDL guard for the two tables above */ }; /* ── Helpers ────────────────────────────────────────────────────── */ @@ -776,6 +783,12 @@ void cbm_store_close(cbm_store_t *s) { finalize_stmt(&s->stmt_delete_file_hash); finalize_stmt(&s->stmt_delete_file_hashes); + finalize_stmt(&s->stmt_ins_node_rev); + finalize_stmt(&s->stmt_del_node_revs); + finalize_stmt(&s->stmt_get_node_revs); + finalize_stmt(&s->stmt_get_rev_meta); + finalize_stmt(&s->stmt_upsert_rev_meta); + /* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes. * Prevents ASan false-positive leaks from sqlite3 internal state. */ sqlite3_close_v2(s->db); @@ -1655,6 +1668,193 @@ int cbm_store_delete_file_hash(cbm_store_t *s, const char *project, const char * return CBM_STORE_OK; } +/* ── Node revisions (per-node git timeline cache) ───────────────── */ + +/* The tables are created lazily on first use (not in init_schema alone) + * because query-path opens (cbm_store_open_path_query) skip init_schema — + * this also upgrades pre-existing DBs in place. Idempotent, flag-guarded. */ +static int ensure_node_rev_schema(cbm_store_t *s) { + if (s->node_rev_schema_ready) { + return CBM_STORE_OK; + } + int rc = exec_sql(s, "CREATE TABLE IF NOT EXISTS node_revisions (" + " project TEXT NOT NULL," + " qualified_name TEXT NOT NULL," + " sha TEXT NOT NULL," + " ts INTEGER NOT NULL DEFAULT 0," + " author TEXT DEFAULT ''," + " subject TEXT DEFAULT ''," + " added INTEGER NOT NULL DEFAULT 0," + " deleted INTEGER NOT NULL DEFAULT 0," + " PRIMARY KEY (project, qualified_name, sha)" + ");" + "CREATE TABLE IF NOT EXISTS node_revision_meta (" + " project TEXT NOT NULL," + " qualified_name TEXT NOT NULL," + " head_sha TEXT NOT NULL," + " PRIMARY KEY (project, qualified_name)" + ");" + "CREATE INDEX IF NOT EXISTS idx_noderev_sha " + "ON node_revisions(project, sha);"); + if (rc == CBM_STORE_OK) { + s->node_rev_schema_ready = true; + } + return rc; +} + +int cbm_store_replace_node_revisions(cbm_store_t *s, const char *project, const char *qn, + const cbm_node_revision_t *revs, int count, + const char *head_sha) { + if (!s || !project || !qn || !head_sha) { + return CBM_STORE_ERR; + } + if (ensure_node_rev_schema(s) != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + if (exec_sql(s, "BEGIN IMMEDIATE;") != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + + sqlite3_stmt *del = + prepare_cached(s, &s->stmt_del_node_revs, + "DELETE FROM node_revisions WHERE project = ?1 AND qualified_name = ?2;"); + if (!del) { + exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } + bind_text(del, ST_COL_1, project); + bind_text(del, ST_COL_2, qn); + if (sqlite3_step(del) != SQLITE_DONE) { + store_set_error_sqlite(s, "delete_node_revisions"); + exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } + + sqlite3_stmt *ins = + prepare_cached(s, &s->stmt_ins_node_rev, + "INSERT OR REPLACE INTO node_revisions " + "(project, qualified_name, sha, ts, author, subject, added, deleted) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);"); + if (!ins) { + exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } + for (int i = 0; i < count; i++) { + sqlite3_reset(ins); + bind_text(ins, ST_COL_1, project); + bind_text(ins, ST_COL_2, qn); + bind_text(ins, ST_COL_3, safe_str(revs[i].sha)); + sqlite3_bind_int64(ins, ST_COL_4, revs[i].ts); + bind_text(ins, ST_COL_5, safe_str(revs[i].author)); + bind_text(ins, ST_COL_6, safe_str(revs[i].subject)); + sqlite3_bind_int(ins, ST_COL_7, revs[i].added); + sqlite3_bind_int(ins, ST_COL_8, revs[i].deleted); + if (sqlite3_step(ins) != SQLITE_DONE) { + store_set_error_sqlite(s, "insert_node_revision"); + exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } + } + + sqlite3_stmt *meta = + prepare_cached(s, &s->stmt_upsert_rev_meta, + "INSERT INTO node_revision_meta (project, qualified_name, head_sha) " + "VALUES (?1, ?2, ?3) " + "ON CONFLICT(project, qualified_name) DO UPDATE SET head_sha=?3;"); + if (!meta) { + exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } + bind_text(meta, ST_COL_1, project); + bind_text(meta, ST_COL_2, qn); + bind_text(meta, ST_COL_3, head_sha); + if (sqlite3_step(meta) != SQLITE_DONE) { + store_set_error_sqlite(s, "upsert_node_revision_meta"); + exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } + + return exec_sql(s, "COMMIT;"); +} + +int cbm_store_get_node_revisions(cbm_store_t *s, const char *project, const char *qn, + cbm_node_revision_t **out, int *count) { + *out = NULL; + *count = 0; + if (ensure_node_rev_schema(s) != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + sqlite3_stmt *stmt = + prepare_cached(s, &s->stmt_get_node_revs, + "SELECT sha, ts, author, subject, added, deleted FROM node_revisions " + "WHERE project = ?1 AND qualified_name = ?2 ORDER BY ts DESC;"); + if (!stmt) { + return CBM_STORE_ERR; + } + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, qn); + + int cap = ST_INIT_CAP_16; + int n = 0; + cbm_node_revision_t *arr = malloc(cap * sizeof(cbm_node_revision_t)); + if (!arr) { + return CBM_STORE_ERR; + } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + if (n >= cap) { + cap *= ST_GROWTH; + arr = safe_realloc(arr, cap * sizeof(cbm_node_revision_t)); + } + arr[n].sha = heap_strdup((const char *)sqlite3_column_text(stmt, 0)); + arr[n].ts = sqlite3_column_int64(stmt, ST_COL_1); + arr[n].author = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_2)); + arr[n].subject = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_3)); + arr[n].added = sqlite3_column_int(stmt, ST_COL_4); + arr[n].deleted = sqlite3_column_int(stmt, ST_COL_5); + n++; + } + + *out = arr; + *count = n; + return CBM_STORE_OK; +} + +void cbm_store_free_node_revisions(cbm_node_revision_t *revs, int count) { + if (!revs) { + return; + } + for (int i = 0; i < count; i++) { + free((void *)revs[i].sha); + free((void *)revs[i].author); + free((void *)revs[i].subject); + } + free(revs); +} + +int cbm_store_get_node_revision_head(cbm_store_t *s, const char *project, const char *qn, + char *out_sha, size_t cap) { + if (cap > 0) { + out_sha[0] = '\0'; + } + if (ensure_node_rev_schema(s) != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_rev_meta, + "SELECT head_sha FROM node_revision_meta " + "WHERE project = ?1 AND qualified_name = ?2;"); + if (!stmt) { + return CBM_STORE_ERR; + } + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, qn); + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *sha = (const char *)sqlite3_column_text(stmt, 0); + snprintf(out_sha, cap, "%s", sha ? sha : ""); + } + return CBM_STORE_OK; +} + int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project) { sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_file_hashes, "DELETE FROM file_hashes WHERE project = ?1;"); diff --git a/src/store/store.h b/src/store/store.h index a20f432de..b24a18adc 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -61,6 +61,37 @@ typedef struct { int64_t size; } cbm_file_hash_t; +/* One commit that touched a node's line range (sparse timeline entry). + * Metadata only — patch text is never stored; it is regenerated on demand + * from git, which is already a content-addressed store of every version. */ +typedef struct { + const char *sha; /* full commit hash */ + int64_t ts; /* author timestamp (unix epoch) */ + const char *author; /* author name */ + const char *subject; + int added; /* lines added to the tracked range in this commit */ + int deleted; /* lines deleted from the tracked range in this commit */ +} cbm_node_revision_t; + +/* Replace the cached timeline of one node atomically (delete + insert + + * meta upsert in a transaction). head_sha records the repo HEAD the + * timeline was computed at — the cache-validity key. */ +int cbm_store_replace_node_revisions(cbm_store_t *s, const char *project, const char *qn, + const cbm_node_revision_t *revs, int count, + const char *head_sha); + +/* Load the cached timeline of one node, newest first. Caller frees via + * cbm_store_free_node_revisions. */ +int cbm_store_get_node_revisions(cbm_store_t *s, const char *project, const char *qn, + cbm_node_revision_t **out, int *count); + +void cbm_store_free_node_revisions(cbm_node_revision_t *revs, int count); + +/* Fetch the HEAD sha a node's cached timeline was computed at into out_sha. + * Returns CBM_STORE_OK with out_sha[0]=='\0' when no cache entry exists. */ +int cbm_store_get_node_revision_head(cbm_store_t *s, const char *project, const char *qn, + char *out_sha, size_t cap); + /* Find nodes overlapping a line range in a file (excludes Module/Package). */ int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path, int start_line, int end_line, cbm_node_t **out, From bd5714e6418277c9b861fa20a62b6e5129a2e94e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 09:29:59 +0000 Subject: [PATCH 2/6] test(node-history): unit suite + testable range mapper; docs for the 15th tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream-readiness pass for get_node_history: - Extract the working-tree→HEAD line-range mapper into exported pure functions (cbm_range_map_init/hunk/finish) so the drift math is unit- testable without git, mirroring the pass_gitdiff parser precedent. - Add tests/test_node_history.c (15 tests): node_revisions CRUD roundtrip/overwrite/isolation on a memory store incl. lazy schema, and range-mapper cases (insert/delete above, overlap, below-range, multi- hunk accumulation, count-omitted single-line hunks, clamping). - Register the suite in test_main.c and Makefile.cbm TEST_STORE_SRCS. - Update tool counts (14 -> 15) in mcp.h/mcp.c/README/CONTRIBUTING and document get_node_history in the README feature list and tool table. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019w17HvR6LS8wk1DPDDdUHH --- CONTRIBUTING.md | 2 +- Makefile.cbm | 3 +- README.md | 6 +- src/mcp/mcp.c | 111 +++++++++------- src/mcp/mcp.h | 22 +++- tests/test_main.c | 2 + tests/test_node_history.c | 257 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 352 insertions(+), 51 deletions(-) create mode 100644 tests/test_node_history.c diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 958aa6f8c..0e53abf0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ src/ foundation/ Arena allocator, hash table, string utils, platform compat store/ SQLite graph storage (WAL mode, FTS5) cypher/ Cypher query → SQL translation - mcp/ MCP server (JSON-RPC 2.0 over stdio, 14 tools) + mcp/ MCP server (JSON-RPC 2.0 over stdio, 15 tools) pipeline/ Multi-pass indexing pipeline pass_*.c Individual pipeline passes (definitions, calls, usages, etc.) httplink.c HTTP route extraction (Go/Express/Laravel/Ktor/Python) diff --git a/Makefile.cbm b/Makefile.cbm index e4bcadd31..cf0f24065 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -289,7 +289,8 @@ TEST_STORE_SRCS = \ tests/test_store_arch.c \ tests/test_store_bulk.c \ tests/test_store_pragmas.c \ - tests/test_store_checkpoint.c + tests/test_store_checkpoint.c \ + tests/test_node_history.c TEST_CYPHER_SRCS = \ tests/test_cypher.c diff --git a/README.md b/README.md index 8b625b1c1..2547a3a49 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Removes all agent configs, skills, hooks, and instructions. Does not remove the - **Architecture Decision Records**: `manage_adr` persists architectural decisions across sessions - **Louvain community detection**: Discovers functional modules by clustering call edges - **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected symbols with risk classification +- **Per-symbol git timeline**: `get_node_history` returns every commit that touched a symbol's line range — lazily computed, cached until HEAD moves, with on-demand diffs and temporal co-change coupling - **Call graph**: Resolves function calls across files and packages (import-aware, type-inferred) - **Dead code detection**: Finds functions with zero callers, excluding entry points - **Cypher-like queries**: `MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'main' RETURN g.name` @@ -324,7 +325,7 @@ Add to `~/.claude/.mcp.json` (global) or project `.mcp.json`: } ``` -Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 14 tools. +Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 15 tools. @@ -389,6 +390,7 @@ codebase-memory-mcp cli --raw search_graph '{"label": "Function"}' | jq '.result | `search_graph` | Structured search by label, name pattern, file pattern, degree filters. Pagination via limit/offset. | | `trace_path` | BFS traversal — who calls a function and what it calls (alias: `trace_call_path`). Depth 1-5. | | `detect_changes` | Map git diff to affected symbols + blast radius with risk classification. | +| `get_node_history` | Git evolution timeline of a symbol: every commit that touched its line range (author, date, line stats), lazily computed and cached. Options: embedded diffs, temporal co-change coupling. | | `query_graph` | Execute Cypher-like graph queries (read-only). | | `get_graph_schema` | Node/edge counts, relationship patterns, property definitions per label. Run this first. | | `get_code_snippet` | Read source code for a function by qualified name. | @@ -527,7 +529,7 @@ Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, A ``` src/ main.c Entry point (MCP stdio server + CLI + install/update/config) - mcp/ MCP server (14 tools, JSON-RPC 2.0, session detection, auto-index) + mcp/ MCP server (15 tools, JSON-RPC 2.0, session detection, auto-index) cli/ Install/uninstall/update/config (10 agents, hooks, instructions) store/ SQLite graph storage (nodes, edges, traversal, search, Louvain) pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → config → tests) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 05dd9a94e..7e8b35cb9 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1,5 +1,5 @@ /* - * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 14 graph tools. + * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 15 graph tools. * * Uses yyjson for fast JSON parsing/building. * Single-threaded event loop: read line → parse → dispatch → respond. @@ -4366,6 +4366,62 @@ static void nh_free_revs(nh_rev_t *revs, int count) { * bypassed: the mapping changes with every edit, not with HEAD), and * *overlap when a hunk intersects the range itself (the node's own body * has uncommitted edits — history is valid only up to HEAD). */ +void cbm_range_map_init(cbm_range_map_t *m, int start, int end) { + memset(m, 0, sizeof(*m)); + m->start = start; + m->end = end; +} + +void cbm_range_map_hunk(cbm_range_map_t *m, const char *diff_line) { + if (strncmp(diff_line, "@@ -", SLEN("@@ -")) != 0) { + return; + } + m->modified = true; + int old_len = SKIP_ONE; + int new_start = 0; + int new_len = SKIP_ONE; + const char *p = diff_line + SLEN("@@ -"); + (void)strtol(p, (char **)&p, MCP_COL_10); + if (*p == ',') { + old_len = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); + } + p = strchr(p, '+'); + if (!p) { + return; + } + new_start = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); + if (*p == ',') { + new_len = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); + } + + /* New-side extent of this hunk (pure deletions occupy no lines). */ + int hunk_new_end = new_len > 0 ? new_start + new_len - SKIP_ONE : new_start; + int shift = new_len - old_len; + + if (hunk_new_end < m->start) { + m->delta_start += shift; + m->delta_end += shift; + } else if (new_start <= m->end) { + m->overlap = true; + m->delta_end += shift; /* approximate: keep the range covering */ + } + /* hunks fully below the range: no effect */ +} + +void cbm_range_map_finish(cbm_range_map_t *m) { + if (!m->modified) { + return; + } + m->start -= m->delta_start; + m->end -= m->delta_end; + if (m->start < SKIP_ONE) { + m->start = SKIP_ONE; + } + if (m->end < m->start) { + m->end = m->start; + } +} + static void nh_map_range_to_head(const char *root_path, const char *file_path, int *start, int *end, bool *modified, bool *overlap) { *modified = false; @@ -4383,56 +4439,19 @@ static void nh_map_range_to_head(const char *root_path, const char *file_path, i return; } - int delta_start = 0; - int delta_end = 0; + cbm_range_map_t map; + cbm_range_map_init(&map, *start, *end); char line[CBM_SZ_1K]; while (fgets(line, sizeof(line), fp)) { - if (strncmp(line, "@@ -", SLEN("@@ -")) != 0) { - continue; - } - *modified = true; - int old_len = SKIP_ONE; - int new_start = 0; - int new_len = SKIP_ONE; - const char *p = line + SLEN("@@ -"); - (void)strtol(p, (char **)&p, MCP_COL_10); - if (*p == ',') { - old_len = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); - } - p = strchr(p, '+'); - if (!p) { - continue; - } - new_start = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); - if (*p == ',') { - new_len = (int)strtol(p + SKIP_ONE, (char **)&p, MCP_COL_10); - } - - /* New-side extent of this hunk (pure deletions occupy no lines). */ - int hunk_new_end = new_len > 0 ? new_start + new_len - SKIP_ONE : new_start; - int shift = new_len - old_len; - - if (hunk_new_end < *start) { - delta_start += shift; - delta_end += shift; - } else if (new_start <= *end) { - *overlap = true; - delta_end += shift; /* approximate: keep the range covering */ - } - /* hunks fully below the range: no effect */ + cbm_range_map_hunk(&map, line); } cbm_pclose(fp); - if (*modified) { - *start -= delta_start; - *end -= delta_end; - if (*start < SKIP_ONE) { - *start = SKIP_ONE; - } - if (*end < *start) { - *end = *start; - } - } + cbm_range_map_finish(&map); + *start = map.start; + *end = map.end; + *modified = map.modified; + *overlap = map.overlap; } /* Read the repo's current HEAD sha into out (empty string on failure). */ diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 121038cbd..325bfee39 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -2,7 +2,7 @@ * mcp.h — MCP (Model Context Protocol) server for codebase-memory-mcp. * * Implements JSON-RPC 2.0 over stdio with the MCP tool calling protocol. - * Provides 14 graph analysis tools (search, trace, query, index, etc.) + * Provides 15 graph analysis tools (search, trace, query, index, etc.) */ #ifndef CBM_MCP_H #define CBM_MCP_H @@ -108,6 +108,26 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line); /* Handle a tools/call request. Returns MCP tool result JSON. */ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json); +/* ── Working-tree → HEAD line-range mapping (get_node_history) ── */ + +/* Maps a line range through the hunks of a `git diff -U0 HEAD` stream so + * uncommitted edits above a symbol don't shift its history tracking onto + * the wrong lines. Feed each diff line to cbm_range_map_hunk (non-header + * lines are ignored), then call cbm_range_map_finish to apply the shift. + * Exposed for unit tests. */ +typedef struct { + int start; /* in: working-tree start line; out: HEAD start line */ + int end; /* in: working-tree end line; out: HEAD end line */ + int delta_start; + int delta_end; + bool modified; /* any hunk seen (file differs from HEAD) */ + bool overlap; /* a hunk intersects the range itself */ +} cbm_range_map_t; + +void cbm_range_map_init(cbm_range_map_t *m, int start, int end); +void cbm_range_map_hunk(cbm_range_map_t *m, const char *diff_line); +void cbm_range_map_finish(cbm_range_map_t *m); + /* ── Idle store eviction ──────────────────────────────────────── */ /* Evict the cached project store if idle for more than timeout_s seconds. diff --git a/tests/test_main.c b/tests/test_main.c index 1f69da988..3eb53dc11 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -65,6 +65,7 @@ extern void suite_store_arch(void); extern void suite_store_bulk(void); extern void suite_store_pragmas(void); extern void suite_store_checkpoint(void); +extern void suite_node_history(void); extern void suite_traces(void); extern void suite_configlink(void); extern void suite_infrascan(void); @@ -120,6 +121,7 @@ int main(void) { RUN_SUITE(store_bulk); RUN_SUITE(store_pragmas); RUN_SUITE(store_checkpoint); + RUN_SUITE(node_history); /* Cypher (M6) */ RUN_SUITE(cypher); diff --git a/tests/test_node_history.c b/tests/test_node_history.c new file mode 100644 index 000000000..eb612b0a1 --- /dev/null +++ b/tests/test_node_history.c @@ -0,0 +1,257 @@ +/* + * test_node_history.c — Tests for the per-node git timeline layer: + * node_revisions store CRUD (lazy schema, replace/get/head roundtrip) + * and the working-tree → HEAD line-range mapper used by get_node_history. + */ +#include "test_framework.h" +#include +#include +#include +#include + +/* ── node_revisions store CRUD ─────────────────────────────────── */ + +TEST(noderev_get_on_fresh_store_is_empty) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + /* Lazy schema: reading before any write must succeed with 0 rows. */ + cbm_node_revision_t *revs = NULL; + int count = -1; + ASSERT_EQ(cbm_store_get_node_revisions(s, "p", "p.f.fn", &revs, &count), CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_node_revisions(revs, count); + cbm_store_close(s); + PASS(); +} + +TEST(noderev_head_empty_when_uncached) { + cbm_store_t *s = cbm_store_open_memory(); + char head[64] = "sentinel"; + ASSERT_EQ(cbm_store_get_node_revision_head(s, "p", "p.f.fn", head, sizeof(head)), CBM_STORE_OK); + ASSERT_STR_EQ(head, ""); + cbm_store_close(s); + PASS(); +} + +TEST(noderev_replace_get_roundtrip) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_node_revision_t in[2] = { + {"aaaa1111", 1000, "alice", "feat: birth", 10, 0}, + {"bbbb2222", 2000, "bob", "fix: guard", 2, 1}, + }; + ASSERT_EQ(cbm_store_replace_node_revisions(s, "p", "p.f.fn", in, 2, "headsha"), CBM_STORE_OK); + + cbm_node_revision_t *out = NULL; + int count = 0; + ASSERT_EQ(cbm_store_get_node_revisions(s, "p", "p.f.fn", &out, &count), CBM_STORE_OK); + ASSERT_EQ(count, 2); + /* Newest first (ORDER BY ts DESC). */ + ASSERT_STR_EQ(out[0].sha, "bbbb2222"); + ASSERT_EQ((int)out[0].ts, 2000); + ASSERT_STR_EQ(out[0].author, "bob"); + ASSERT_STR_EQ(out[0].subject, "fix: guard"); + ASSERT_EQ(out[0].added, 2); + ASSERT_EQ(out[0].deleted, 1); + ASSERT_STR_EQ(out[1].sha, "aaaa1111"); + cbm_store_free_node_revisions(out, count); + + char head[64]; + ASSERT_EQ(cbm_store_get_node_revision_head(s, "p", "p.f.fn", head, sizeof(head)), CBM_STORE_OK); + ASSERT_STR_EQ(head, "headsha"); + cbm_store_close(s); + PASS(); +} + +TEST(noderev_replace_overwrites_previous) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_node_revision_t v1[2] = { + {"aaaa1111", 1000, "alice", "one", 1, 0}, + {"bbbb2222", 2000, "bob", "two", 1, 0}, + }; + ASSERT_EQ(cbm_store_replace_node_revisions(s, "p", "p.f.fn", v1, 2, "head1"), CBM_STORE_OK); + + cbm_node_revision_t v2[1] = {{"cccc3333", 3000, "carol", "three", 5, 5}}; + ASSERT_EQ(cbm_store_replace_node_revisions(s, "p", "p.f.fn", v2, 1, "head2"), CBM_STORE_OK); + + cbm_node_revision_t *out = NULL; + int count = 0; + ASSERT_EQ(cbm_store_get_node_revisions(s, "p", "p.f.fn", &out, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(out[0].sha, "cccc3333"); + cbm_store_free_node_revisions(out, count); + + char head[64]; + cbm_store_get_node_revision_head(s, "p", "p.f.fn", head, sizeof(head)); + ASSERT_STR_EQ(head, "head2"); + cbm_store_close(s); + PASS(); +} + +TEST(noderev_empty_replace_caches_no_history) { + cbm_store_t *s = cbm_store_open_memory(); + /* A node with zero history still gets a cache-validity marker. */ + ASSERT_EQ(cbm_store_replace_node_revisions(s, "p", "p.f.fn", NULL, 0, "headsha"), CBM_STORE_OK); + cbm_node_revision_t *out = NULL; + int count = -1; + ASSERT_EQ(cbm_store_get_node_revisions(s, "p", "p.f.fn", &out, &count), CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_node_revisions(out, count); + char head[64]; + cbm_store_get_node_revision_head(s, "p", "p.f.fn", head, sizeof(head)); + ASSERT_STR_EQ(head, "headsha"); + cbm_store_close(s); + PASS(); +} + +TEST(noderev_isolated_per_node_and_project) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_node_revision_t a[1] = {{"aaaa1111", 1000, "alice", "a", 1, 0}}; + cbm_node_revision_t b[1] = {{"bbbb2222", 2000, "bob", "b", 1, 0}}; + ASSERT_EQ(cbm_store_replace_node_revisions(s, "p1", "p1.f.fn", a, 1, "h1"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_replace_node_revisions(s, "p2", "p2.f.fn", b, 1, "h2"), CBM_STORE_OK); + + cbm_node_revision_t *out = NULL; + int count = 0; + cbm_store_get_node_revisions(s, "p1", "p1.f.fn", &out, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(out[0].sha, "aaaa1111"); + cbm_store_free_node_revisions(out, count); + + cbm_store_get_node_revisions(s, "p1", "p2.f.fn", &out, &count); + ASSERT_EQ(count, 0); + cbm_store_free_node_revisions(out, count); + cbm_store_close(s); + PASS(); +} + +/* ── Working-tree → HEAD range mapping ─────────────────────────── */ + +/* Node at working-tree lines 100-120 in all mapping tests below. */ + +TEST(rangemap_clean_file_is_identity) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + cbm_range_map_finish(&m); + ASSERT_EQ(m.start, 100); + ASSERT_EQ(m.end, 120); + ASSERT_EQ(m.modified, false); + ASSERT_EQ(m.overlap, false); + PASS(); +} + +TEST(rangemap_insertion_above_shifts_up) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + /* 10 lines inserted at working-tree line 50 (0 old lines). */ + cbm_range_map_hunk(&m, "@@ -49,0 +50,10 @@ context\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.start, 90); + ASSERT_EQ(m.end, 110); + ASSERT_EQ(m.modified, true); + ASSERT_EQ(m.overlap, false); + PASS(); +} + +TEST(rangemap_deletion_above_shifts_down) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + /* 5 lines deleted after working-tree line 50 (0 new lines). */ + cbm_range_map_hunk(&m, "@@ -51,5 +50,0 @@ context\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.start, 105); + ASSERT_EQ(m.end, 125); + ASSERT_EQ(m.overlap, false); + PASS(); +} + +TEST(rangemap_hunk_below_no_effect) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + cbm_range_map_hunk(&m, "@@ -200,3 +200,8 @@ context\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.start, 100); + ASSERT_EQ(m.end, 120); + ASSERT_EQ(m.modified, true); /* file differs from HEAD... */ + ASSERT_EQ(m.overlap, false); /* ...but not inside the range */ + PASS(); +} + +TEST(rangemap_overlap_sets_flag) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + /* Edit inside the node body: 3 lines became 6 at line 110. */ + cbm_range_map_hunk(&m, "@@ -110,3 +110,6 @@ body\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.overlap, true); + ASSERT_EQ(m.start, 100); + /* End keeps covering: shifted by the net growth. */ + ASSERT_EQ(m.end, 117); + PASS(); +} + +TEST(rangemap_multiple_hunks_accumulate) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + cbm_range_map_hunk(&m, "@@ -9,0 +10,20 @@ a\n"); /* +20 above */ + cbm_range_map_hunk(&m, "@@ -41,5 +60,0 @@ b\n"); /* -5 above */ + cbm_range_map_hunk(&m, "@@ -300,2 +300,9 @@ c\n"); /* below: ignored */ + cbm_range_map_finish(&m); + ASSERT_EQ(m.start, 100 - 20 + 5); + ASSERT_EQ(m.end, 120 - 20 + 5); + ASSERT_EQ(m.overlap, false); + PASS(); +} + +TEST(rangemap_single_line_hunk_omits_count) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + /* git omits ",1" for single-line spans: one line replaced at 50. */ + cbm_range_map_hunk(&m, "@@ -50 +50 @@ context\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.start, 100); /* 1 old, 1 new → zero shift */ + ASSERT_EQ(m.end, 120); + ASSERT_EQ(m.modified, true); + PASS(); +} + +TEST(rangemap_ignores_non_hunk_lines) { + cbm_range_map_t m; + cbm_range_map_init(&m, 100, 120); + cbm_range_map_hunk(&m, "diff --git a/f.c b/f.c\n"); + cbm_range_map_hunk(&m, "+++ b/f.c\n"); + cbm_range_map_hunk(&m, "+int x = 1;\n"); + cbm_range_map_hunk(&m, "-int x = 0;\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.modified, false); + ASSERT_EQ(m.start, 100); + PASS(); +} + +TEST(rangemap_start_clamped_to_one) { + cbm_range_map_t m; + cbm_range_map_init(&m, 3, 10); + /* Pathological: 50 deleted lines "above" line 3 per hunk math. */ + cbm_range_map_hunk(&m, "@@ -1,52 +1,2 @@ x\n"); + cbm_range_map_finish(&m); + ASSERT(m.start >= 1); + ASSERT(m.end >= m.start); + PASS(); +} + +SUITE(node_history) { + RUN_TEST(noderev_get_on_fresh_store_is_empty); + RUN_TEST(noderev_head_empty_when_uncached); + RUN_TEST(noderev_replace_get_roundtrip); + RUN_TEST(noderev_replace_overwrites_previous); + RUN_TEST(noderev_empty_replace_caches_no_history); + RUN_TEST(noderev_isolated_per_node_and_project); + RUN_TEST(rangemap_clean_file_is_identity); + RUN_TEST(rangemap_insertion_above_shifts_up); + RUN_TEST(rangemap_deletion_above_shifts_down); + RUN_TEST(rangemap_hunk_below_no_effect); + RUN_TEST(rangemap_overlap_sets_flag); + RUN_TEST(rangemap_multiple_hunks_accumulate); + RUN_TEST(rangemap_single_line_hunk_omits_count); + RUN_TEST(rangemap_ignores_non_hunk_lines); + RUN_TEST(rangemap_start_clamped_to_one); +} From 12d99fc677cafe4c47bc5eed77c9f04d5702ad22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 00:20:29 +0000 Subject: [PATCH 3/6] chore(security): document get_node_history popen call sites in allowlist The audit is file:function pair-based so src/mcp/mcp.c:cbm_popen already passes, but CONTRIBUTING requires each new popen call site to carry an explicit justification entry for human review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019w17HvR6LS8wk1DPDDdUHH --- scripts/security-allowlist.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/security-allowlist.txt b/scripts/security-allowlist.txt index f84d860d6..2406be3f5 100644 --- a/scripts/security-allowlist.txt +++ b/scripts/security-allowlist.txt @@ -27,6 +27,10 @@ src/mcp/mcp.c:cbm_popen:search_code via grep (pattern in temp file, path validat src/mcp/mcp.c:cbm_popen:detect_changes via git diff (args validated) src/mcp/mcp.c:cbm_popen:git ls-files count for auto-index (session_root validated) src/mcp/mcp.c:cbm_popen:update check to api.github.com (hardcoded URL) +src/mcp/mcp.c:cbm_popen:get_node_history git log -L timeline (root+file validated via validate_search_path_arg) +src/mcp/mcp.c:cbm_popen:get_node_history git rev-parse HEAD (root validated) +src/mcp/mcp.c:cbm_popen:get_node_history git diff -U0 range mapping (root+file validated) +src/mcp/mcp.c:cbm_popen:get_node_history git show co-change (shas from git output, root validated) src/mcp/mcp.c:popen:via cbm_popen wrapper calls # ── Pipeline: git history parsing (fallback when libgit2 not available) ──── From 27a27cd1e7ef664af4276bc7319fb6937b3e11e7 Mon Sep 17 00:00:00 2001 From: RiceTooCold Date: Tue, 7 Jul 2026 20:32:00 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(mcp):=20get=5Fnode=5Fhistory=20correctn?= =?UTF-8?q?ess=20=E2=80=94=20serve=20from=20source,=20cache=20validity,=20?= =?UTF-8?q?staleness=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from an 8-angle adversarial pass, all verified against live repros before fixing: - Patch misattribution: emit computed results straight from git's output (miss path) and sha-match patches on cache hits — the write-then-read- back roundtrip paired `git log` order with `ORDER BY ts DESC` by index, attaching diffs to the wrong commit after rebases/cherry-picks. - Silent-empty timelines: persist is now best-effort on the miss path (response no longer depends on the store write), and a failed cache read on a hit returns an explicit error instead of total_revisions: 0. - Staleness guard: compare the file's mtime+size against its file_hashes row (new cbm_store_get_file_hash) before trusting indexed line numbers; stale files get an actionable re-index error instead of a double-shifted range whose wrong history was then cached under HEAD. - Cache poisoning: overlap-approximated (dirty) computations are no longer persisted, so a later clean tree at the same HEAD can't serve them as authoritative hits. - Uncommitted symbols: a pure-insertion hunk swallowing the whole range now short-circuits to total_revisions: 0 with an explanatory note — previously git log died past HEAD's EOF and the error blamed a stale index that reindexing could never fix. - include_patch no longer defeats the cache: warm hits re-run git with --max-count=patch_limit (all calls now bounded by NH_MAX_REVS) and skip the redundant cache rewrite; edits entirely below a symbol (zero shift, no overlap) no longer void its cache. - added/deleted counters: hunk-boundary state machine instead of a bare +++/--- prefix test, which miscounted column-0 content like "--i;" or SQL "-- comment" lines (reproduced against real git output). - Schema: node_revisions/node_revision_meta gain the same REFERENCES projects(name) ON DELETE CASCADE as every other per-project table (delete_project no longer orphans timeline rows); dropped the unqueried idx_noderev_sha; deterministic ORDER BY tiebreaker. - nh_append_patch: O(n) via tracked length; co_changed command buffer offset clamped; three duplicate stat_mtime_ns copies consolidated into platform.h cbm_stat_mtime_ns. - Docs/tests: installer template, README, npm README now list all 15 tools incl. get_node_history; tools/list and skill-content assertions updated; new mapper tests for the uncommitted-detection cases. Full suite: 4971 passed; the 88 reds are main's intentional probe reproduction suites, byte-identical to baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019c3No8anx3TLVa2n5uBAnC --- README.md | 4 +- pkg/npm/README.md | 6 +- src/cli/cli.c | 6 +- src/foundation/platform.h | 16 ++ src/mcp/mcp.c | 252 ++++++++++++++++++++-------- src/mcp/mcp.h | 6 +- src/pipeline/pipeline.c | 13 +- src/pipeline/pipeline_incremental.c | 16 +- src/store/store.c | 30 +++- src/store/store.h | 5 + tests/test_cli.c | 3 +- tests/test_mcp.c | 3 +- tests/test_node_history.c | 37 ++++ 13 files changed, 284 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 2547a3a49..3394d5b30 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ **The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a single static binary for macOS, Linux, and Windows — download, run `install`, done. -High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 159 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, and C++ — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 11 coding agents. +High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 159 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, and C++ — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. Zero dependencies. Plug and play across 11 coding agents. > **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration. @@ -37,7 +37,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si - **11 agents, one command** — `install` auto-detects Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro — configures MCP entries, instruction files, and pre-tool hooks for each. - **Built-in graph visualization** — 3D interactive UI at `localhost:9749` (optional UI binary variant). - **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources. -- **14 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. +- **15 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. ## Quick Start diff --git a/pkg/npm/README.md b/pkg/npm/README.md index 81dbc1761..bd8e9222e 100644 --- a/pkg/npm/README.md +++ b/pkg/npm/README.md @@ -7,7 +7,7 @@ **The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a single static binary — this package downloads and runs it automatically. -High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across 159 languages — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 11 coding agents. +High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across 159 languages — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. Zero dependencies. Plug and play across 11 coding agents. ## Installation @@ -30,7 +30,7 @@ Restart your agent. Say **"Index this project"** — done. - **159 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks. - **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. - **11 agents, one command** — `install` auto-detects Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro. -- **14 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. +- **15 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. ## Supported Platforms @@ -67,7 +67,7 @@ codebase-memory-mcp cli get_architecture '{}' |----------|-------| | **Indexing** | `index_repository`, `list_projects`, `delete_project`, `index_status` | | **Querying** | `search_graph`, `trace_call_path`, `detect_changes`, `query_graph` | -| **Analysis** | `get_architecture`, `get_graph_schema`, `get_code_snippet`, `search_code` | +| **Analysis** | `get_architecture`, `get_graph_schema`, `get_code_snippet`, `get_node_history`, `search_code` | | **Advanced** | `manage_adr`, `ingest_traces` | ## Performance diff --git a/src/cli/cli.c b/src/cli/cli.c index 4d05285c8..82b71da48 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -453,11 +453,11 @@ static const char skill_content[] = "- High fan-in: `search_graph(min_degree=10, relationship=\"CALLS\", " "direction=\"inbound\")`\n" "\n" - "## 14 MCP Tools\n" + "## 15 MCP Tools\n" "`index_repository`, `index_status`, `list_projects`, `delete_project`,\n" "`search_graph`, `search_code`, `trace_path`, `detect_changes`,\n" - "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_architecture`,\n" - "`manage_adr`, `ingest_traces`\n" + "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_node_history`,\n" + "`get_architecture`, `manage_adr`, `ingest_traces`\n" "\n" "## Edge Types\n" "CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD,\n" diff --git a/src/foundation/platform.h b/src/foundation/platform.h index 2511a060e..e1394689b 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -14,6 +14,22 @@ #include #include #include +#include + +/* ── Platform-portable mtime_ns ───────────────────────────────── */ + +/* Nanosecond mtime from a stat buffer (second-granularity on Windows). */ +static inline int64_t cbm_stat_mtime_ns(const struct stat *st) { + enum { CBM_STAT_NS_PER_SEC = 1000000000 }; +#ifdef __APPLE__ + return ((int64_t)st->st_mtimespec.tv_sec * CBM_STAT_NS_PER_SEC) + + (int64_t)st->st_mtimespec.tv_nsec; +#elif defined(_WIN32) + return (int64_t)st->st_mtime * CBM_STAT_NS_PER_SEC; +#else + return ((int64_t)st->st_mtim.tv_sec * CBM_STAT_NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec; +#endif +} /* ── Safe memory ──────────────────────────────────────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7e8b35cb9..8e9a4de49 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4213,6 +4213,7 @@ typedef struct { int added; int deleted; char *patch; + size_t patch_len; bool patch_truncated; } nh_rev_t; @@ -4259,39 +4260,42 @@ static bool nh_parse_commit_line(const char *line, nh_rev_t *rev) { /* Append a line to rev->patch, growing it up to NH_PATCH_CAP. */ static void nh_append_patch(nh_rev_t *rev, const char *line) { - size_t cur = rev->patch ? strlen(rev->patch) : 0; + if (rev->patch_truncated) { + return; + } size_t add = strlen(line); - if (cur + add >= NH_PATCH_CAP) { + if (rev->patch_len + add >= NH_PATCH_CAP) { rev->patch_truncated = true; return; } - char *grown = realloc(rev->patch, cur + add + SKIP_ONE); + char *grown = realloc(rev->patch, rev->patch_len + add + SKIP_ONE); if (!grown) { return; } - if (cur == 0) { - grown[0] = '\0'; - } rev->patch = grown; - memcpy(rev->patch + cur, line, add + SKIP_ONE); + memcpy(rev->patch + rev->patch_len, line, add + SKIP_ONE); + rev->patch_len += add; } /* Run `git log -L,:` and parse the commit stream. * want_patch: capture diff text for the first patch_limit revisions. + * max_count bounds how far git traces the range back (and how many + * revisions are returned) — pass NH_MAX_REVS for the full timeline. * Returns revision count, or -1 when git failed and nothing was parsed. */ static int nh_run_git_log(const char *root_path, const char *file_path, int start_line, - int end_line, bool want_patch, int patch_limit, nh_rev_t **out) { + int end_line, bool want_patch, int patch_limit, int max_count, + nh_rev_t **out) { char cmd[CBM_SZ_2K]; #ifdef _WIN32 snprintf(cmd, sizeof(cmd), - "git -C \"%s\" log \"-L%d,%d:%s\" " + "git -C \"%s\" log \"-L%d,%d:%s\" --max-count=%d " "\"--format=@@C@@%%H%%x1f%%at%%x1f%%an%%x1f%%s\" 2>NUL", - root_path, start_line, end_line, file_path); + root_path, start_line, end_line, file_path, max_count); #else snprintf(cmd, sizeof(cmd), - "git -C '%s' log '-L%d,%d:%s' " + "git -C '%s' log '-L%d,%d:%s' --max-count=%d " "'--format=@@C@@%%H%%x1f%%at%%x1f%%an%%x1f%%s' 2>/dev/null", - root_path, start_line, end_line, file_path); + root_path, start_line, end_line, file_path, max_count); #endif FILE *fp = cbm_popen(cmd, "r"); @@ -4306,6 +4310,7 @@ static int nh_run_git_log(const char *root_path, const char *file_path, int star } int n = 0; nh_rev_t *cur = NULL; + bool in_hunk = false; char line[CBM_SZ_4K]; while (fgets(line, sizeof(line), fp)) { @@ -4319,16 +4324,25 @@ static int nh_run_git_log(const char *root_path, const char *file_path, int star } else { cur = NULL; } + in_hunk = false; continue; } if (!cur) { continue; } - /* Diff body: count range-scoped adds/deletes, skipping headers. */ - if (line[0] == '+' && strncmp(line, "+++", SLEN("+++")) != 0) { - cur->added++; - } else if (line[0] == '-' && strncmp(line, "---", SLEN("---")) != 0) { - cur->deleted++; + /* Track hunk boundaries so +/- counting never mistakes content + * lines like "--i;" or "-- SQL comment" (rendered "---…"/"+++…") + * for the file headers that precede the first "@@" of a diff. */ + if (strncmp(line, "diff ", SLEN("diff ")) == 0) { + in_hunk = false; + } else if (strncmp(line, "@@", SLEN("@@")) == 0) { + in_hunk = true; + } else if (in_hunk) { + if (line[0] == '+') { + cur->added++; + } else if (line[0] == '-') { + cur->deleted++; + } } if (want_patch && (cur - revs) < patch_limit) { nh_append_patch(cur, line); @@ -4404,6 +4418,12 @@ void cbm_range_map_hunk(cbm_range_map_t *m, const char *diff_line) { } else if (new_start <= m->end) { m->overlap = true; m->delta_end += shift; /* approximate: keep the range covering */ + if (old_len == 0 && new_start <= m->start && hunk_new_end >= m->end) { + /* Pure insertion swallowing the whole range: the symbol has no + * lines at HEAD, so there is no history to track (and a mapped + * start could point past HEAD's EOF, making git log -L fail). */ + m->uncommitted = true; + } } /* hunks fully below the range: no effect */ } @@ -4423,9 +4443,10 @@ void cbm_range_map_finish(cbm_range_map_t *m) { } static void nh_map_range_to_head(const char *root_path, const char *file_path, int *start, int *end, - bool *modified, bool *overlap) { + bool *modified, bool *overlap, bool *uncommitted) { *modified = false; *overlap = false; + *uncommitted = false; char cmd[CBM_SZ_2K]; #ifdef _WIN32 @@ -4452,6 +4473,7 @@ static void nh_map_range_to_head(const char *root_path, const char *file_path, i *end = map.end; *modified = map.modified; *overlap = map.overlap; + *uncommitted = map.uncommitted; } /* Read the repo's current HEAD sha into out (empty string on failure). */ @@ -4479,8 +4501,7 @@ static void nh_git_head(const char *root_path, char *out, size_t cap) { /* Temporal coupling: count files co-occurring with node_file across the * node's commits. Emits the top files as {file, co_changes} into arr. */ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const char *root_path, - const char *node_file, const cbm_node_revision_t *revs, int count) { - int nsha = count < NH_COCHANGE_SHAS ? count : NH_COCHANGE_SHAS; + const char *node_file, const char *const *shas, int nsha) { if (nsha == 0) { return; } @@ -4496,8 +4517,11 @@ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const ch const char *devnull = "2>/dev/null"; #endif int off = snprintf(cmd, sizeof(cmd), prefix_fmt, root_path); + if (off < 0 || off >= (int)sizeof(cmd)) { + return; /* root_path too long for a well-formed command */ + } for (int i = 0; i < nsha && off < (int)sizeof(cmd) - NH_SHA_BUF - CBM_SZ_16; i++) { - off += snprintf(cmd + off, sizeof(cmd) - off, "%s ", revs[i].sha); + off += snprintf(cmd + off, sizeof(cmd) - off, "%s ", shas[i]); } snprintf(cmd + off, sizeof(cmd) - off, "%s", devnull); @@ -4558,6 +4582,32 @@ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const ch } } +/* Append one revision object to arr. patch may be NULL. */ +static void nh_emit_rev(yyjson_mut_doc *doc, yyjson_mut_val *arr, const char *sha, int64_t ts, + const char *author, const char *subject, int added, int deleted, + const char *patch, bool patch_truncated) { + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "sha", sha); + char date[NH_DATE_BUF] = ""; + time_t tt = (time_t)ts; + struct tm tmv; + if (cbm_gmtime_r(&tt, &tmv) != NULL) { + strftime(date, sizeof(date), "%Y-%m-%d", &tmv); + } + yyjson_mut_obj_add_strcpy(doc, item, "date", date); + yyjson_mut_obj_add_strcpy(doc, item, "author", author); + yyjson_mut_obj_add_strcpy(doc, item, "subject", subject); + yyjson_mut_obj_add_int(doc, item, "added", added); + yyjson_mut_obj_add_int(doc, item, "deleted", deleted); + if (patch) { + yyjson_mut_obj_add_strcpy(doc, item, "patch", patch); + if (patch_truncated) { + yyjson_mut_obj_add_bool(doc, item, "patch_truncated", true); + } + } + yyjson_mut_arr_add_val(arr, item); +} + static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { char *qn = cbm_mcp_get_string_arg(args, "qualified_name"); char *project = cbm_mcp_get_string_arg(args, "project"); @@ -4649,60 +4699,114 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result("not a git repository (or git not installed)", true); } + /* Staleness guard: node line numbers were captured at index time. If + * the file changed since (mtime+size vs its hash row), they no longer + * anchor the working tree and the HEAD mapping below would shift the + * range onto the wrong lines. Reindexing refreshes the hash row, so + * the advice is actionable. */ + int64_t idx_mtime = 0; + int64_t idx_size = 0; + if (cbm_store_get_file_hash(store, effective_project, node.file_path, &idx_mtime, &idx_size) == + CBM_STORE_OK) { + char abs_path[CBM_SZ_2K]; + snprintf(abs_path, sizeof(abs_path), "%s/%s", root_path, node.file_path); + struct stat st; + if (stat(abs_path, &st) == 0 && + (cbm_stat_mtime_ns(&st) != idx_mtime || st.st_size != idx_size)) { + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result( + "file changed since it was last indexed — node line numbers are stale. " + "Re-run index_repository and retry.", + true); + } + } + /* Uncommitted edits shift indexed line numbers away from HEAD — map * the range back to HEAD coordinates before tracking. */ int track_start = node.start_line; int track_end = node.end_line; bool wt_modified = false; bool wt_overlap = false; + bool wt_uncommitted = false; nh_map_range_to_head(root_path, node.file_path, &track_start, &track_end, &wt_modified, - &wt_overlap); + &wt_overlap, &wt_uncommitted); - /* Cache check: valid while the repo HEAD hasn't moved AND the file is - * clean (a dirty file remaps on every edit, so bypass the cache). - * Patch text is never cached (sparse storage) — include_patch always - * re-runs git. */ + /* Cache check: valid while the repo HEAD hasn't moved AND the mapped + * range is exact — clean file, or edits that leave the symbol's HEAD + * range untouched (no overlap, zero shift). Patch text is never cached + * (sparse storage) — include_patch re-runs git, bounded to patch_limit + * commits on a hit. */ char cached_head[NH_SHA_BUF]; cbm_store_get_node_revision_head(store, effective_project, node.qualified_name, cached_head, sizeof(cached_head)); - bool cache_hit = !wt_modified && cached_head[0] != '\0' && strcmp(cached_head, head) == 0; + bool range_exact = !wt_modified || (!wt_overlap && track_start == node.start_line && + track_end == node.end_line); + bool cache_hit = range_exact && cached_head[0] != '\0' && strcmp(cached_head, head) == 0; nh_rev_t *fresh = NULL; int fresh_count = 0; - if (!cache_hit || include_patch) { + if (wt_uncommitted) { + /* The whole symbol is a working-tree insertion: it has no lines at + * HEAD, so there is no history to compute (and nothing to cache). */ + cache_hit = false; + } else if (!cache_hit || include_patch) { + int max_count = (cache_hit && include_patch) ? patch_limit : NH_MAX_REVS; fresh_count = nh_run_git_log(root_path, node.file_path, track_start, track_end, - include_patch, patch_limit, &fresh); + include_patch, patch_limit, max_count, &fresh); if (fresh_count < 0) { free(root_path); free_node_contents(&node); free(qn); free(project); return cbm_mcp_text_result( - "git log failed for this range — the index may be stale (file changed since " - "last index). Re-run index_repository and retry.", + wt_overlap ? "git log failed for this range — the symbol has uncommitted edits; " + "history is only available once its lines exist at HEAD" + : "git log failed for this range — the index may be stale (file " + "changed since last index). Re-run index_repository and retry.", true); } - /* Persist metadata (sparse: no patch text). */ - cbm_node_revision_t *rows = calloc(fresh_count ? fresh_count : SKIP_ONE, sizeof(*rows)); - if (rows) { - for (int i = 0; i < fresh_count; i++) { - rows[i].sha = fresh[i].sha; - rows[i].ts = fresh[i].ts; - rows[i].author = fresh[i].author; - rows[i].subject = fresh[i].subject; - rows[i].added = fresh[i].added; - rows[i].deleted = fresh[i].deleted; + /* Persist metadata (sparse: no patch text) — only for full, exact + * computations: a patch-limited re-run on a hit must not truncate + * the cached timeline, and an overlap-approximated range must not + * poison the entry served for a later clean tree at the same HEAD. */ + if (!cache_hit && !wt_overlap) { + cbm_node_revision_t *rows = + calloc(fresh_count ? fresh_count : SKIP_ONE, sizeof(*rows)); + if (rows) { + for (int i = 0; i < fresh_count; i++) { + rows[i].sha = fresh[i].sha; + rows[i].ts = fresh[i].ts; + rows[i].author = fresh[i].author; + rows[i].subject = fresh[i].subject; + rows[i].added = fresh[i].added; + rows[i].deleted = fresh[i].deleted; + } + cbm_store_replace_node_revisions(store, effective_project, node.qualified_name, + rows, fresh_count, head); + free(rows); } - cbm_store_replace_node_revisions(store, effective_project, node.qualified_name, rows, - fresh_count, head); - free(rows); } } - /* Serve from the cache table so both paths share one shape. */ + /* Serve computed results from `fresh` directly (git's newest-first + * order, patches aligned by construction); read the cache table only + * on a hit. The persist above is best-effort — a store failure must + * not turn a freshly computed timeline into a silent empty result. */ cbm_node_revision_t *revs = NULL; int rev_count = 0; - cbm_store_get_node_revisions(store, effective_project, node.qualified_name, &revs, &rev_count); + if (cache_hit && cbm_store_get_node_revisions(store, effective_project, node.qualified_name, + &revs, &rev_count) != CBM_STORE_OK) { + nh_free_revs(fresh, fresh_count); + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result("failed to read cached history from the graph DB", true); + } + int total = cache_hit ? rev_count : fresh_count; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root_obj = yyjson_mut_obj(doc); @@ -4718,42 +4822,52 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, root_obj, "tracked_start_at_head", track_start); yyjson_mut_obj_add_int(doc, root_obj, "tracked_end_at_head", track_end); } - if (wt_overlap) { + if (wt_uncommitted) { + yyjson_mut_obj_add_strcpy(doc, root_obj, "note", + "this symbol is not committed at HEAD yet — its history " + "starts with its first commit"); + } else if (wt_overlap) { yyjson_mut_obj_add_strcpy(doc, root_obj, "note", "this symbol has uncommitted edits — history reflects HEAD; " "the working-tree change is not a commit yet"); } - yyjson_mut_obj_add_int(doc, root_obj, "total_revisions", rev_count); + yyjson_mut_obj_add_int(doc, root_obj, "total_revisions", total); yyjson_mut_val *arr = yyjson_mut_arr(doc); - int emit = rev_count < limit ? rev_count : limit; - for (int i = 0; i < emit; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "sha", revs[i].sha); - char date[NH_DATE_BUF] = ""; - time_t tt = (time_t)revs[i].ts; - struct tm tmv; - if (cbm_gmtime_r(&tt, &tmv) != NULL) { - strftime(date, sizeof(date), "%Y-%m-%d", &tmv); - } - yyjson_mut_obj_add_strcpy(doc, item, "date", date); - yyjson_mut_obj_add_strcpy(doc, item, "author", revs[i].author); - yyjson_mut_obj_add_strcpy(doc, item, "subject", revs[i].subject); - yyjson_mut_obj_add_int(doc, item, "added", revs[i].added); - yyjson_mut_obj_add_int(doc, item, "deleted", revs[i].deleted); - if (include_patch && fresh && i < fresh_count && i < patch_limit && fresh[i].patch) { - yyjson_mut_obj_add_strcpy(doc, item, "patch", fresh[i].patch); - if (fresh[i].patch_truncated) { - yyjson_mut_obj_add_bool(doc, item, "patch_truncated", true); + int emit = total < limit ? total : limit; + if (cache_hit) { + for (int i = 0; i < emit; i++) { + const char *patch = NULL; + bool truncated = false; + for (int j = 0; include_patch && fresh && j < fresh_count && j < patch_limit; j++) { + if (fresh[j].patch && strcmp(fresh[j].sha, revs[i].sha) == 0) { + patch = fresh[j].patch; + truncated = fresh[j].patch_truncated; + break; + } } + nh_emit_rev(doc, arr, revs[i].sha, revs[i].ts, revs[i].author, revs[i].subject, + revs[i].added, revs[i].deleted, patch, truncated); + } + } else { + for (int i = 0; i < emit; i++) { + bool with_patch = include_patch && i < patch_limit && fresh[i].patch; + nh_emit_rev(doc, arr, fresh[i].sha, fresh[i].ts, fresh[i].author, fresh[i].subject, + fresh[i].added, fresh[i].deleted, with_patch ? fresh[i].patch : NULL, + with_patch && fresh[i].patch_truncated); } - yyjson_mut_arr_add_val(arr, item); } yyjson_mut_obj_add_val(doc, root_obj, "revisions", arr); if (co_changed) { yyjson_mut_val *cc = yyjson_mut_arr(doc); - nh_add_co_changed(doc, cc, root_path, node.file_path, revs, rev_count); + const char *shas[NH_COCHANGE_SHAS]; + int nsha = 0; + int src_count = cache_hit ? rev_count : fresh_count; + for (int i = 0; i < src_count && nsha < NH_COCHANGE_SHAS; i++) { + shas[nsha++] = cache_hit ? revs[i].sha : fresh[i].sha; + } + nh_add_co_changed(doc, cc, root_path, node.file_path, shas, nsha); yyjson_mut_obj_add_val(doc, root_obj, "co_changed_files", cc); } diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 325bfee39..72d20b1bb 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -120,8 +120,10 @@ typedef struct { int end; /* in: working-tree end line; out: HEAD end line */ int delta_start; int delta_end; - bool modified; /* any hunk seen (file differs from HEAD) */ - bool overlap; /* a hunk intersects the range itself */ + bool modified; /* any hunk seen (file differs from HEAD) */ + bool overlap; /* a hunk intersects the range itself */ + bool uncommitted; /* the whole range sits inside a pure-insertion hunk: + the symbol does not exist at HEAD (no history) */ } cbm_range_map_t; void cbm_range_map_init(cbm_range_map_t *m, int start, int end); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 4b02de9cd..1d60c4ce6 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -757,17 +757,6 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file return CBM_NOT_FOUND; } -/* Get platform-specific mtime in nanoseconds. */ -static int64_t stat_mtime_ns(const struct stat *fst) { -#ifdef __APPLE__ - return ((int64_t)fst->st_mtimespec.tv_sec * PL_NSEC_PER_SEC) + - (int64_t)fst->st_mtimespec.tv_nsec; -#elif defined(_WIN32) - return (int64_t)fst->st_mtime * 1000000000LL; -#else - return ((int64_t)fst->st_mtim.tv_sec * PL_NSEC_PER_SEC) + (int64_t)fst->st_mtim.tv_nsec; -#endif -} /* Dump graph to SQLite and persist file hashes for incremental indexing. */ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *files, int file_count, @@ -803,7 +792,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil struct stat fst; if (stat(files[i].path, &fst) == 0) { cbm_store_upsert_file_hash(hash_store, p->project_name, files[i].rel_path, "", - stat_mtime_ns(&fst), fst.st_size); + cbm_stat_mtime_ns(&fst), fst.st_size); } } diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 34668bf05..69d713c9f 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -58,18 +58,6 @@ static const char *itoa_buf(int v) { return buf[idx]; } -/* ── Platform-portable mtime_ns ──────────────────────────────────── */ - -static int64_t stat_mtime_ns(const struct stat *st) { -#ifdef __APPLE__ - return ((int64_t)st->st_mtimespec.tv_sec * CBM_NS_PER_SEC) + (int64_t)st->st_mtimespec.tv_nsec; -#elif defined(_WIN32) - return (int64_t)st->st_mtime * CBM_NS_PER_SEC; -#else - return ((int64_t)st->st_mtim.tv_sec * CBM_NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec; -#endif -} - /* ── File classification ─────────────────────────────────────────── */ /* Classify discovered files against stored hashes using mtime+size. @@ -108,7 +96,7 @@ static bool *classify_files(cbm_file_info_t *files, int file_count, cbm_file_has continue; } - if (stat_mtime_ns(&st) != h->mtime_ns || st.st_size != h->size) { + if (cbm_stat_mtime_ns(&st) != h->mtime_ns || st.st_size != h->size) { changed[i] = true; n_changed++; } else { @@ -324,7 +312,7 @@ static void persist_hashes(cbm_store_t *store, const char *project, cbm_file_inf continue; } int rc = cbm_store_upsert_file_hash(store, project, files[i].rel_path, "", - stat_mtime_ns(&st), st.st_size); + cbm_stat_mtime_ns(&st), st.st_size); if (rc != CBM_STORE_OK) { cbm_log_warn("incremental.persist_hash_failed", "scope", "current", "rel_path", files[i].rel_path, "rc", itoa_buf(rc)); diff --git a/src/store/store.c b/src/store/store.c index 42a3558d5..a3147121e 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -131,6 +131,7 @@ struct cbm_store { sqlite3_stmt *stmt_upsert_file_hash; sqlite3_stmt *stmt_get_file_hashes; + sqlite3_stmt *stmt_get_file_hash; sqlite3_stmt *stmt_delete_file_hash; sqlite3_stmt *stmt_delete_file_hashes; @@ -780,6 +781,7 @@ void cbm_store_close(cbm_store_t *s) { finalize_stmt(&s->stmt_upsert_file_hash); finalize_stmt(&s->stmt_get_file_hashes); + finalize_stmt(&s->stmt_get_file_hash); finalize_stmt(&s->stmt_delete_file_hash); finalize_stmt(&s->stmt_delete_file_hashes); @@ -1651,6 +1653,24 @@ int cbm_store_get_file_hashes(cbm_store_t *s, const char *project, cbm_file_hash return CBM_STORE_OK; } +int cbm_store_get_file_hash(cbm_store_t *s, const char *project, const char *rel_path, + int64_t *mtime_ns, int64_t *size) { + sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_file_hash, + "SELECT mtime_ns, size FROM file_hashes " + "WHERE project = ?1 AND rel_path = ?2;"); + if (!stmt) { + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + bind_text(stmt, CBM_SZ_2, rel_path); + if (sqlite3_step(stmt) != SQLITE_ROW) { + return CBM_STORE_NOT_FOUND; + } + *mtime_ns = sqlite3_column_int64(stmt, 0); + *size = sqlite3_column_int64(stmt, SKIP_ONE); + return CBM_STORE_OK; +} + int cbm_store_delete_file_hash(cbm_store_t *s, const char *project, const char *rel_path) { sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_file_hash, @@ -1678,7 +1698,7 @@ static int ensure_node_rev_schema(cbm_store_t *s) { return CBM_STORE_OK; } int rc = exec_sql(s, "CREATE TABLE IF NOT EXISTS node_revisions (" - " project TEXT NOT NULL," + " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," " qualified_name TEXT NOT NULL," " sha TEXT NOT NULL," " ts INTEGER NOT NULL DEFAULT 0," @@ -1689,13 +1709,11 @@ static int ensure_node_rev_schema(cbm_store_t *s) { " PRIMARY KEY (project, qualified_name, sha)" ");" "CREATE TABLE IF NOT EXISTS node_revision_meta (" - " project TEXT NOT NULL," + " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," " qualified_name TEXT NOT NULL," " head_sha TEXT NOT NULL," " PRIMARY KEY (project, qualified_name)" - ");" - "CREATE INDEX IF NOT EXISTS idx_noderev_sha " - "ON node_revisions(project, sha);"); + ");"); if (rc == CBM_STORE_OK) { s->node_rev_schema_ready = true; } @@ -1787,7 +1805,7 @@ int cbm_store_get_node_revisions(cbm_store_t *s, const char *project, const char sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_node_revs, "SELECT sha, ts, author, subject, added, deleted FROM node_revisions " - "WHERE project = ?1 AND qualified_name = ?2 ORDER BY ts DESC;"); + "WHERE project = ?1 AND qualified_name = ?2 ORDER BY ts DESC, sha;"); if (!stmt) { return CBM_STORE_ERR; } diff --git a/src/store/store.h b/src/store/store.h index b24a18adc..f7d02f4e8 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -400,6 +400,11 @@ int cbm_store_upsert_file_hash(cbm_store_t *s, const char *project, const char * int cbm_store_get_file_hashes(cbm_store_t *s, const char *project, cbm_file_hash_t **out, int *count); +/* Single-file hash lookup (staleness checks). Returns CBM_STORE_OK, + * CBM_STORE_NOT_FOUND when the file has no hash row, or CBM_STORE_ERR. */ +int cbm_store_get_file_hash(cbm_store_t *s, const char *project, const char *rel_path, + int64_t *mtime_ns, int64_t *size); + int cbm_store_delete_file_hash(cbm_store_t *s, const char *project, const char *rel_path); int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project); diff --git a/tests/test_cli.c b/tests/test_cli.c index add431385..da721115a 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -568,7 +568,8 @@ TEST(cli_skill_files_content) { /* Reference capabilities */ ASSERT(strstr(sk[0].content, "query_graph") != NULL); ASSERT(strstr(sk[0].content, "Cypher") != NULL); - ASSERT(strstr(sk[0].content, "14 MCP Tools") != NULL); + ASSERT(strstr(sk[0].content, "15 MCP Tools") != NULL); + ASSERT(strstr(sk[0].content, "get_node_history") != NULL); /* Gotchas section */ ASSERT(strstr(sk[0].content, "Gotchas") != NULL); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 61c71b897..152b5f68a 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -167,12 +167,13 @@ TEST(mcp_initialize_response) { TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); - /* Should contain all 14 tools */ + /* Should contain all 15 tools */ ASSERT_NOT_NULL(strstr(json, "index_repository")); ASSERT_NOT_NULL(strstr(json, "search_graph")); ASSERT_NOT_NULL(strstr(json, "query_graph")); ASSERT_NOT_NULL(strstr(json, "trace_path")); ASSERT_NOT_NULL(strstr(json, "get_code_snippet")); + ASSERT_NOT_NULL(strstr(json, "get_node_history")); ASSERT_NOT_NULL(strstr(json, "get_graph_schema")); ASSERT_NOT_NULL(strstr(json, "get_architecture")); ASSERT_NOT_NULL(strstr(json, "search_code")); diff --git a/tests/test_node_history.c b/tests/test_node_history.c index eb612b0a1..43d1cf2bc 100644 --- a/tests/test_node_history.c +++ b/tests/test_node_history.c @@ -35,6 +35,7 @@ TEST(noderev_head_empty_when_uncached) { TEST(noderev_replace_get_roundtrip) { cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "p", "/tmp/p"); cbm_node_revision_t in[2] = { {"aaaa1111", 1000, "alice", "feat: birth", 10, 0}, {"bbbb2222", 2000, "bob", "fix: guard", 2, 1}, @@ -64,6 +65,7 @@ TEST(noderev_replace_get_roundtrip) { TEST(noderev_replace_overwrites_previous) { cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "p", "/tmp/p"); cbm_node_revision_t v1[2] = { {"aaaa1111", 1000, "alice", "one", 1, 0}, {"bbbb2222", 2000, "bob", "two", 1, 0}, @@ -89,6 +91,7 @@ TEST(noderev_replace_overwrites_previous) { TEST(noderev_empty_replace_caches_no_history) { cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "p", "/tmp/p"); /* A node with zero history still gets a cache-validity marker. */ ASSERT_EQ(cbm_store_replace_node_revisions(s, "p", "p.f.fn", NULL, 0, "headsha"), CBM_STORE_OK); cbm_node_revision_t *out = NULL; @@ -105,6 +108,8 @@ TEST(noderev_empty_replace_caches_no_history) { TEST(noderev_isolated_per_node_and_project) { cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "p1", "/tmp/p1"); + cbm_store_upsert_project(s, "p2", "/tmp/p2"); cbm_node_revision_t a[1] = {{"aaaa1111", 1000, "alice", "a", 1, 0}}; cbm_node_revision_t b[1] = {{"bbbb2222", 2000, "bob", "b", 1, 0}}; ASSERT_EQ(cbm_store_replace_node_revisions(s, "p1", "p1.f.fn", a, 1, "h1"), CBM_STORE_OK); @@ -238,6 +243,36 @@ TEST(rangemap_start_clamped_to_one) { PASS(); } +TEST(rangemap_pure_insertion_covering_range_is_uncommitted) { + cbm_range_map_t m; + /* Brand-new 50-line function appended at working-tree lines 500-549 + * of a file whose HEAD version ends at line 400: one insertion hunk + * swallows the whole range — the symbol has no lines at HEAD. */ + cbm_range_map_init(&m, 500, 549); + cbm_range_map_hunk(&m, "@@ -400,0 +401,150 @@ tail\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.uncommitted, true); + ASSERT_EQ(m.overlap, true); + PASS(); +} + +TEST(rangemap_partial_overlap_is_not_uncommitted) { + cbm_range_map_t m; + /* Edit hunk (old lines exist) overlapping the range: committed symbol + * with uncommitted edits — history at HEAD still exists. */ + cbm_range_map_init(&m, 100, 120); + cbm_range_map_hunk(&m, "@@ -110,3 +110,6 @@ body\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.overlap, true); + ASSERT_EQ(m.uncommitted, false); + /* Insertion inside the range but not covering it: same. */ + cbm_range_map_init(&m, 100, 120); + cbm_range_map_hunk(&m, "@@ -109,0 +110,5 @@ body\n"); + cbm_range_map_finish(&m); + ASSERT_EQ(m.uncommitted, false); + PASS(); +} + SUITE(node_history) { RUN_TEST(noderev_get_on_fresh_store_is_empty); RUN_TEST(noderev_head_empty_when_uncached); @@ -254,4 +289,6 @@ SUITE(node_history) { RUN_TEST(rangemap_single_line_hunk_omits_count); RUN_TEST(rangemap_ignores_non_hunk_lines); RUN_TEST(rangemap_start_clamped_to_one); + RUN_TEST(rangemap_pure_insertion_covering_range_is_uncommitted); + RUN_TEST(rangemap_partial_overlap_is_not_uncommitted); } From 3c2bf1b0ede38b915843f6f9dfdd9ee70ab00082 Mon Sep 17 00:00:00 2001 From: RiceTooCold Date: Wed, 8 Jul 2026 16:06:30 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(mcp):=20get=5Fnode=5Fhistory=20bounds?= =?UTF-8?q?=20=E2=80=94=20git=20deadline,=20since/until=20window,=20shallo?= =?UTF-8?q?w=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three operational bounds for the history walk: - cbm_proc_*: deadline-bounded command reader in the foundation layer (POSIX pipe+fork of sh -c in its own process group, poll-driven reads, kill on expiry so close never blocks; Windows falls back to _popen without a deadline — documented follow-up). The four get_node_history git call sites move from cbm_popen to it: 30s for the -L walk, 10s for rev-parse / diff / show. A timed-out walk is refused, not served partially, with advice to bound the query. - since/until: charset-validated git dates passed through to the walk. Windowed queries bypass the cache both ways — partial timelines are neither served from nor persisted to the full-timeline cache. - shallow: detected in the same rev-parse invocation as HEAD; the response says the timeline is truncated by clone depth, not history. Tests: git-date validation, proc reader (lines/exit status, fgets-style splits, deadline kill). Security allowlist updated for the new call sites. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019c3No8anx3TLVa2n5uBAnC --- scripts/security-allowlist.txt | 11 +- src/foundation/compat_fs.c | 151 ++++++++++++++++++++ src/foundation/compat_fs.h | 36 +++++ src/mcp/mcp.c | 248 +++++++++++++++++++++++++-------- src/mcp/mcp.h | 4 + tests/test_node_history.c | 86 ++++++++++++ 6 files changed, 477 insertions(+), 59 deletions(-) diff --git a/scripts/security-allowlist.txt b/scripts/security-allowlist.txt index 2406be3f5..b93b59467 100644 --- a/scripts/security-allowlist.txt +++ b/scripts/security-allowlist.txt @@ -9,6 +9,9 @@ src/foundation/compat_fs.c:popen:cbm_popen wrapper definition (POSIX) src/foundation/compat_fs.c:cbm_popen:cbm_popen function definition src/foundation/compat_fs.c:fork:cbm_exec_no_shell — fork+execvp for shell-free subprocess execution src/foundation/compat_fs.c:execvp:cbm_exec_no_shell — direct exec without shell interpretation +src/foundation/compat_fs.c:fork:cbm_proc_open — deadline-bounded command reader (kills runaway children) +src/foundation/compat_fs.c:execl:cbm_proc_open — sh -c in own process group so timeouts can kill the whole tree +src/foundation/compat_fs.c:_popen:cbm_proc_open Windows fallback (no deadline; documented follow-up) # ── CLI: update command (user-initiated, interactive) ────────────────────── src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd) @@ -27,10 +30,10 @@ src/mcp/mcp.c:cbm_popen:search_code via grep (pattern in temp file, path validat src/mcp/mcp.c:cbm_popen:detect_changes via git diff (args validated) src/mcp/mcp.c:cbm_popen:git ls-files count for auto-index (session_root validated) src/mcp/mcp.c:cbm_popen:update check to api.github.com (hardcoded URL) -src/mcp/mcp.c:cbm_popen:get_node_history git log -L timeline (root+file validated via validate_search_path_arg) -src/mcp/mcp.c:cbm_popen:get_node_history git rev-parse HEAD (root validated) -src/mcp/mcp.c:cbm_popen:get_node_history git diff -U0 range mapping (root+file validated) -src/mcp/mcp.c:cbm_popen:get_node_history git show co-change (shas from git output, root validated) +src/mcp/mcp.c:cbm_proc_open:get_node_history git log -L timeline (root+file validated, since/until charset-validated, 30s deadline) +src/mcp/mcp.c:cbm_proc_open:get_node_history git rev-parse HEAD + shallow probe (root validated, 10s deadline) +src/mcp/mcp.c:cbm_proc_open:get_node_history git diff -U0 range mapping (root+file validated, 10s deadline) +src/mcp/mcp.c:cbm_proc_open:get_node_history git show co-change (shas from git output, root validated, 10s deadline) src/mcp/mcp.c:popen:via cbm_popen wrapper calls # ── Pipeline: git history parsing (fallback when libgit2 not available) ──── diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index f77ad9a89..567c23028 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -130,6 +130,24 @@ int cbm_pclose(FILE *f) { return _pclose(f); } +/* Windows: no deadline enforcement — _popen exposes no process handle to + * kill, and the callers' git commands are already bounded by --max-count. + * Proper CreateProcess-based timeouts are a documented follow-up. */ +bool cbm_proc_open(cbm_proc_t *p, const char *cmd, int timeout_ms) { + (void)timeout_ms; + p->timed_out = false; + p->fp = _popen(cmd, "r"); + return p->fp != NULL; +} + +bool cbm_proc_gets(cbm_proc_t *p, char *out, size_t cap) { + return fgets(out, (int)cap, p->fp) != NULL; +} + +int cbm_proc_close(cbm_proc_t *p) { + return _pclose(p->fp); +} + bool cbm_mkdir_p(const char *path, int mode) { (void)mode; wchar_t *wpath = cbm_utf8_to_wide(path); @@ -194,8 +212,11 @@ int cbm_exec_no_shell(const char *const *argv) { #include #include +#include +#include #include #include +#include #include struct cbm_dir { @@ -262,6 +283,136 @@ int cbm_pclose(FILE *f) { return pclose(f); } +enum { CBM_MS_PER_SEC = 1000, CBM_NS_PER_MS = 1000000, CBM_PROC_EXEC_FAIL = 127 }; + +static long long proc_now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * CBM_MS_PER_SEC + ts.tv_nsec / CBM_NS_PER_MS; +} + +bool cbm_proc_open(cbm_proc_t *p, const char *cmd, int timeout_ms) { + memset(p, 0, sizeof(*p)); + p->fd = -1; + + int fds[2]; + if (pipe(fds) != 0) { + return false; + } + pid_t pid = fork(); + if (pid < 0) { + close(fds[0]); + close(fds[1]); + return false; + } + if (pid == 0) { + /* Own process group so a timeout can kill the shell AND whatever + * it spawned (git), not just the shell. */ + setpgid(0, 0); + close(fds[0]); + dup2(fds[1], STDOUT_FILENO); + close(fds[1]); + execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); + _exit(CBM_PROC_EXEC_FAIL); + } + close(fds[1]); + setpgid(pid, pid); /* mirror the child's call so a kill can never race it */ + p->fd = fds[0]; + p->pid = (int)pid; + p->deadline_ms = timeout_ms > 0 ? proc_now_ms() + timeout_ms : 0; + return true; +} + +/* Emit up to cap-1 buffered bytes ending at the first newline (or the + * whole buffer when force is set / it is cap-filling). fgets semantics. */ +static bool proc_emit(cbm_proc_t *p, char *out, size_t cap, bool force) { + size_t take = 0; + while (take < p->buf_len && p->buf[take] != '\n') { + take++; + } + if (take < p->buf_len) { + take++; /* include the newline */ + } else if (!force && p->buf_len < cap - SKIP_ONE) { + return false; /* no full line buffered yet */ + } + if (take == 0) { + return false; + } + if (take > cap - SKIP_ONE) { + take = cap - SKIP_ONE; + } + memcpy(out, p->buf, take); + out[take] = '\0'; + p->buf_len -= take; + memmove(p->buf, p->buf + take, p->buf_len); + return true; +} + +bool cbm_proc_gets(cbm_proc_t *p, char *out, size_t cap) { + if (cap < PAIR_LEN || p->timed_out) { + return false; + } + for (;;) { + if (proc_emit(p, out, cap, p->eof || p->buf_len == sizeof(p->buf))) { + return true; + } + if (p->eof) { + return false; + } + int wait_ms = -1; + if (p->deadline_ms > 0) { + long long remaining = p->deadline_ms - proc_now_ms(); + if (remaining <= 0) { + p->timed_out = true; + return false; + } + wait_ms = (int)remaining; + } + struct pollfd pfd = {.fd = p->fd, .events = POLLIN}; + int prc = poll(&pfd, 1, wait_ms); + if (prc == 0) { + p->timed_out = true; + return false; + } + if (prc < 0) { + if (errno == EINTR) { + continue; + } + p->eof = true; + continue; + } + ssize_t got = read(p->fd, p->buf + p->buf_len, sizeof(p->buf) - p->buf_len); + if (got > 0) { + p->buf_len += (size_t)got; + } else if (got == 0 || errno != EINTR) { + p->eof = true; + } + } +} + +int cbm_proc_close(cbm_proc_t *p) { + if (p->fd >= 0) { + close(p->fd); + p->fd = -1; + } + if (p->pid <= 0) { + return -1; + } + if (p->timed_out && kill(-(pid_t)p->pid, SIGKILL) != 0) { + kill((pid_t)p->pid, SIGKILL); + } + int status = 0; + pid_t rc; + do { + rc = waitpid((pid_t)p->pid, &status, 0); + } while (rc < 0 && errno == EINTR); + p->pid = 0; + if (p->timed_out || rc < 0 || !WIFEXITED(status)) { + return -1; + } + return WEXITSTATUS(status); +} + bool cbm_mkdir_p(const char *path, int mode) { /* Try direct mkdir first */ if (mkdir(path, (mode_t)mode) == 0) { diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index 285ad555b..9d23a75f2 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -39,6 +39,42 @@ void cbm_closedir(cbm_dir_t *d); FILE *cbm_popen(const char *cmd, const char *mode); int cbm_pclose(FILE *f); +/* ── Deadline-bounded command reader ──────────────────────────── */ + +/* popen("r")-like handle whose reads observe a wall-clock deadline, so a + * runaway subprocess (e.g. an expensive git history walk) cannot hang the + * caller. POSIX: pipe + fork of `sh -c cmd` in its own process group — + * on expiry the group is killed and close() never blocks. Windows: plain + * _popen with no deadline enforcement (documented follow-up); timed_out + * stays false. */ +enum { CBM_PROC_BUF = 4096 }; + +typedef struct { +#ifdef _WIN32 + FILE *fp; +#else + int fd; + int pid; + long long deadline_ms; /* CLOCK_MONOTONIC, ms; 0 = no deadline */ + size_t buf_len; + bool eof; + char buf[CBM_PROC_BUF]; +#endif + bool timed_out; +} cbm_proc_t; + +/* Start `sh -c cmd` with stdout piped to the handle. timeout_ms <= 0 + * means no deadline. Returns false when the process could not start. */ +bool cbm_proc_open(cbm_proc_t *p, const char *cmd, int timeout_ms); + +/* Read the next line (newline kept, fgets-style: at most cap-1 bytes). + * Returns false on EOF or deadline expiry — check p->timed_out. */ +bool cbm_proc_gets(cbm_proc_t *p, char *out, size_t cap); + +/* Reap the child. Returns its exit code, or -1 when it was killed on + * timeout, did not exit normally, or could not be reaped. */ +int cbm_proc_close(cbm_proc_t *p); + /* ── File operations ──────────────────────────────────────────── */ /* Create directory (and parents). mode is ignored on Windows. Returns true on success. */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 8e9a4de49..fa5a29ba3 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -68,6 +68,7 @@ enum { #include #include // int64_t #include +#include #include #include #include @@ -442,7 +443,10 @@ static const tool_def_t TOOLS[] = { "git line-range tracking on first call, then cached in the graph DB until HEAD moves. " "Options: include_patch returns the actual diffs of the most recent revisions (regenerated " "from git on demand, never stored); co_changed returns files that most often changed in " - "the same commits as this symbol — hidden coupling that static edges miss. " + "the same commits as this symbol — hidden coupling that static edges miss; since/until " + "bound the walk to a time window (cheaper on large repos; windowed results bypass the " + "cache). The response sets shallow:true when the repo is a shallow clone — the timeline " + "is then truncated by clone depth, not real history. " "IMPORTANT: First call search_graph to find the exact qualified_name, then pass it here.", "{\"type\":\"object\",\"properties\":{\"qualified_name\":{\"type\":\"string\"," "\"description\":\"Full qualified_name from search_graph, or short symbol name " @@ -454,7 +458,10 @@ static const tool_def_t TOOLS[] = { "\"patch_limit\":{\"type\":\"integer\",\"default\":3,\"description\":\"How many revision " "diffs to embed when include_patch is true\"}," "\"co_changed\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Also return " - "files that repeatedly changed in the same commits as this symbol (temporal coupling)\"}}," + "files that repeatedly changed in the same commits as this symbol (temporal coupling)\"}," + "\"since\":{\"type\":\"string\",\"description\":\"Only revisions after this git date " + "(e.g. \\\"2026-01-01\\\", \\\"6 months ago\\\")\"}," + "\"until\":{\"type\":\"string\",\"description\":\"Only revisions up to this git date\"}}," "\"required\":[\"qualified_name\",\"project\"]}"}, {"manage_adr", "Create or update Architecture Decision Records", @@ -4202,8 +4209,28 @@ enum { NH_SHA_BUF = 48, NH_DATE_BUF = 24, NH_US = 0x1f, /* ASCII unit separator used in the git format string */ + NH_ARG_MAX = 64, /* max length of a since/until value */ + NH_GIT_LOG_TIMEOUT_MS = 30000, /* deadline for the -L history walk */ + NH_GIT_FAST_TIMEOUT_MS = 10000, /* deadline for cheap git commands */ + NH_GIT_ERR = -1, /* git failed and nothing was parsed */ + NH_GIT_TIMEOUT = -2, /* the walk hit its deadline */ }; +/* Validate a user-supplied git date ("2026-01-01", "3 months ago") for + * safe embedding in a quoted shell command: conservative allowlist with + * no quoting, expansion, or redirection characters. */ +bool cbm_nh_valid_git_date(const char *s) { + if (!s || !*s || strlen(s) > NH_ARG_MAX) { + return false; + } + for (const char *p = s; *p; p++) { + if (!isalnum((unsigned char)*p) && !strchr(" .:+-/T", *p)) { + return false; + } + } + return true; +} + /* One parsed revision. patch is heap-allocated only when capturing diffs. */ typedef struct { char sha[NH_SHA_BUF]; @@ -4258,6 +4285,8 @@ static bool nh_parse_commit_line(const char *line, nh_rev_t *rev) { return true; } +static void nh_free_revs(nh_rev_t *revs, int count); + /* Append a line to rev->patch, growing it up to NH_PATCH_CAP. */ static void nh_append_patch(nh_rev_t *rev, const char *line) { if (rev->patch_truncated) { @@ -4277,43 +4306,67 @@ static void nh_append_patch(nh_rev_t *rev, const char *line) { rev->patch_len += add; } +/* Options for one history walk. since/until are pre-validated git dates + * (cbm_nh_valid_git_date) or NULL; timed_out reports a deadline expiry. */ +typedef struct { + bool want_patch; /* capture diff text for the first patch_limit revisions */ + int patch_limit; + int max_count; /* walk bound — pass NH_MAX_REVS for the full timeline */ + const char *since; + const char *until; + bool timed_out; +} nh_log_opts_t; + /* Run `git log -L,:` and parse the commit stream. - * want_patch: capture diff text for the first patch_limit revisions. - * max_count bounds how far git traces the range back (and how many - * revisions are returned) — pass NH_MAX_REVS for the full timeline. - * Returns revision count, or -1 when git failed and nothing was parsed. */ + * Returns revision count, NH_GIT_ERR when git failed and nothing was + * parsed, or NH_GIT_TIMEOUT when the walk hit its deadline. */ static int nh_run_git_log(const char *root_path, const char *file_path, int start_line, - int end_line, bool want_patch, int patch_limit, int max_count, - nh_rev_t **out) { + int end_line, nh_log_opts_t *opts, nh_rev_t **out) { + char window[CBM_SZ_256] = ""; + int woff = 0; +#ifdef _WIN32 + const char *win_fmt[] = {" \"--since=%s\"", " \"--until=%s\""}; +#else + const char *win_fmt[] = {" '--since=%s'", " '--until=%s'"}; +#endif + if (opts->since) { + woff += snprintf(window + woff, sizeof(window) - (size_t)woff, win_fmt[0], opts->since); + } + if (opts->until && woff >= 0 && woff < (int)sizeof(window)) { + snprintf(window + woff, sizeof(window) - (size_t)woff, win_fmt[SKIP_ONE], opts->until); + } + char cmd[CBM_SZ_2K]; #ifdef _WIN32 snprintf(cmd, sizeof(cmd), - "git -C \"%s\" log \"-L%d,%d:%s\" --max-count=%d " + "git -C \"%s\" log \"-L%d,%d:%s\" --max-count=%d%s " "\"--format=@@C@@%%H%%x1f%%at%%x1f%%an%%x1f%%s\" 2>NUL", - root_path, start_line, end_line, file_path, max_count); + root_path, start_line, end_line, file_path, opts->max_count, window); #else snprintf(cmd, sizeof(cmd), - "git -C '%s' log '-L%d,%d:%s' --max-count=%d " + "git -C '%s' log '-L%d,%d:%s' --max-count=%d%s " "'--format=@@C@@%%H%%x1f%%at%%x1f%%an%%x1f%%s' 2>/dev/null", - root_path, start_line, end_line, file_path, max_count); + root_path, start_line, end_line, file_path, opts->max_count, window); #endif - FILE *fp = cbm_popen(cmd, "r"); - if (!fp) { - return -1; + cbm_proc_t proc; + if (!cbm_proc_open(&proc, cmd, NH_GIT_LOG_TIMEOUT_MS)) { + return NH_GIT_ERR; } nh_rev_t *revs = calloc(NH_MAX_REVS, sizeof(nh_rev_t)); if (!revs) { - cbm_pclose(fp); - return -1; + cbm_proc_close(&proc); + return NH_GIT_ERR; } + bool want_patch = opts->want_patch; + int patch_limit = opts->patch_limit; int n = 0; nh_rev_t *cur = NULL; bool in_hunk = false; char line[CBM_SZ_4K]; - while (fgets(line, sizeof(line), fp)) { + while (cbm_proc_gets(&proc, line, sizeof(line))) { if (strncmp(line, "@@C@@", SLEN("@@C@@")) == 0) { if (n >= NH_MAX_REVS) { break; @@ -4348,10 +4401,17 @@ static int nh_run_git_log(const char *root_path, const char *file_path, int star nh_append_patch(cur, line); } } - int status = cbm_pclose(fp); + int status = cbm_proc_close(&proc); + if (proc.timed_out) { + /* A partial timeline would silently misrepresent the history — + * refuse it and let the caller suggest a bounded query. */ + nh_free_revs(revs, n); + opts->timed_out = true; + return NH_GIT_TIMEOUT; + } if (n == 0 && status != 0) { free(revs); - return -1; + return NH_GIT_ERR; } *out = revs; return n; @@ -4442,8 +4502,10 @@ void cbm_range_map_finish(cbm_range_map_t *m) { } } -static void nh_map_range_to_head(const char *root_path, const char *file_path, int *start, int *end, - bool *modified, bool *overlap, bool *uncommitted) { +/* Returns false when the diff hit its deadline — the mapping is then + * unknown and the caller must not serve possibly-misanchored history. */ +static bool nh_map_range_to_head(const char *root_path, const char *file_path, int *start, + int *end, bool *modified, bool *overlap, bool *uncommitted) { *modified = false; *overlap = false; *uncommitted = false; @@ -4455,18 +4517,21 @@ static void nh_map_range_to_head(const char *root_path, const char *file_path, i snprintf(cmd, sizeof(cmd), "git -C '%s' diff -U0 HEAD -- '%s' 2>/dev/null", root_path, file_path); #endif - FILE *fp = cbm_popen(cmd, "r"); - if (!fp) { - return; + cbm_proc_t proc; + if (!cbm_proc_open(&proc, cmd, NH_GIT_FAST_TIMEOUT_MS)) { + return true; /* same as the old popen-failure path: assume clean */ } cbm_range_map_t map; cbm_range_map_init(&map, *start, *end); char line[CBM_SZ_1K]; - while (fgets(line, sizeof(line), fp)) { + while (cbm_proc_gets(&proc, line, sizeof(line))) { cbm_range_map_hunk(&map, line); } - cbm_pclose(fp); + cbm_proc_close(&proc); + if (proc.timed_out) { + return false; + } cbm_range_map_finish(&map); *start = map.start; @@ -4474,28 +4539,41 @@ static void nh_map_range_to_head(const char *root_path, const char *file_path, i *modified = map.modified; *overlap = map.overlap; *uncommitted = map.uncommitted; + return true; } -/* Read the repo's current HEAD sha into out (empty string on failure). */ -static void nh_git_head(const char *root_path, char *out, size_t cap) { +/* Read the repo's current HEAD sha into out (empty string on failure) + * and whether the repo is a shallow clone — one rev-parse invocation + * prints both, in argument order. */ +static void nh_git_head(const char *root_path, char *out, size_t cap, bool *shallow) { out[0] = '\0'; + *shallow = false; char cmd[CBM_SZ_1K]; #ifdef _WIN32 - snprintf(cmd, sizeof(cmd), "git -C \"%s\" rev-parse HEAD 2>NUL", root_path); + snprintf(cmd, sizeof(cmd), "git -C \"%s\" rev-parse HEAD --is-shallow-repository 2>NUL", + root_path); #else - snprintf(cmd, sizeof(cmd), "git -C '%s' rev-parse HEAD 2>/dev/null", root_path); + snprintf(cmd, sizeof(cmd), "git -C '%s' rev-parse HEAD --is-shallow-repository 2>/dev/null", + root_path); #endif - FILE *fp = cbm_popen(cmd, "r"); - if (!fp) { + cbm_proc_t proc; + if (!cbm_proc_open(&proc, cmd, NH_GIT_FAST_TIMEOUT_MS)) { return; } - if (fgets(out, (int)cap, fp)) { + if (cbm_proc_gets(&proc, out, cap)) { size_t len = strlen(out); while (len > 0 && (out[len - SKIP_ONE] == '\n' || out[len - SKIP_ONE] == '\r')) { out[--len] = '\0'; } + char flag[CBM_SZ_16]; + if (cbm_proc_gets(&proc, flag, sizeof(flag))) { + *shallow = strncmp(flag, "true", SLEN("true")) == 0; + } + } + cbm_proc_close(&proc); + if (proc.timed_out) { + out[0] = '\0'; } - cbm_pclose(fp); } /* Temporal coupling: count files co-occurring with node_file across the @@ -4525,8 +4603,8 @@ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const ch } snprintf(cmd + off, sizeof(cmd) - off, "%s", devnull); - FILE *fp = cbm_popen(cmd, "r"); - if (!fp) { + cbm_proc_t proc; + if (!cbm_proc_open(&proc, cmd, NH_GIT_FAST_TIMEOUT_MS)) { return; } @@ -4535,7 +4613,9 @@ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const ch int nfiles = 0; char line[CBM_SZ_1K]; - while (fgets(line, sizeof(line), fp)) { + /* On timeout the loop just ends: co-change degrades to a partial (or + * empty) list rather than failing the whole history response. */ + while (cbm_proc_gets(&proc, line, sizeof(line))) { size_t len = strlen(line); while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) { line[--len] = '\0'; @@ -4561,7 +4641,7 @@ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const ch counts[idx]++; } } - cbm_pclose(fp); + cbm_proc_close(&proc); /* Emit the top entries by count (selection over a small fixed array). */ for (int rank = 0; rank < NH_COCHANGE_TOP; rank++) { @@ -4690,7 +4770,8 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { } char head[NH_SHA_BUF]; - nh_git_head(root_path, head, sizeof(head)); + bool repo_shallow = false; + nh_git_head(root_path, head, sizeof(head), &repo_shallow); if (head[0] == '\0') { free(root_path); free_node_contents(&node); @@ -4724,6 +4805,24 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { } } + /* Time window: validated git dates bound the walk. Windowed queries + * bypass the cache both ways — a partial timeline must neither be + * served from nor persisted to the full-timeline cache. */ + char *since = cbm_mcp_get_string_arg(args, "since"); + char *until = cbm_mcp_get_string_arg(args, "until"); + if ((since && !cbm_nh_valid_git_date(since)) || (until && !cbm_nh_valid_git_date(until))) { + free(since); + free(until); + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result("invalid since/until — use a git date like \"2026-01-01\" " + "or \"3 months ago\"", + true); + } + bool windowed = since != NULL || until != NULL; + /* Uncommitted edits shift indexed line numbers away from HEAD — map * the range back to HEAD coordinates before tracking. */ int track_start = node.start_line; @@ -4731,8 +4830,18 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { bool wt_modified = false; bool wt_overlap = false; bool wt_uncommitted = false; - nh_map_range_to_head(root_path, node.file_path, &track_start, &track_end, &wt_modified, - &wt_overlap, &wt_uncommitted); + if (!nh_map_range_to_head(root_path, node.file_path, &track_start, &track_end, &wt_modified, + &wt_overlap, &wt_uncommitted)) { + free(since); + free(until); + free(root_path); + free_node_contents(&node); + free(qn); + free(project); + return cbm_mcp_text_result("git diff timed out while mapping the symbol's line range — " + "the repository is too expensive to query right now", + true); + } /* Cache check: valid while the repo HEAD hasn't moved AND the mapped * range is exact — clean file, or edits that leave the symbol's HEAD @@ -4744,7 +4853,8 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { sizeof(cached_head)); bool range_exact = !wt_modified || (!wt_overlap && track_start == node.start_line && track_end == node.end_line); - bool cache_hit = range_exact && cached_head[0] != '\0' && strcmp(cached_head, head) == 0; + bool cache_hit = !windowed && range_exact && cached_head[0] != '\0' && + strcmp(cached_head, head) == 0; nh_rev_t *fresh = NULL; int fresh_count = 0; @@ -4753,26 +4863,39 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { * HEAD, so there is no history to compute (and nothing to cache). */ cache_hit = false; } else if (!cache_hit || include_patch) { - int max_count = (cache_hit && include_patch) ? patch_limit : NH_MAX_REVS; - fresh_count = nh_run_git_log(root_path, node.file_path, track_start, track_end, - include_patch, patch_limit, max_count, &fresh); + nh_log_opts_t opts = { + .want_patch = include_patch, + .patch_limit = patch_limit, + .max_count = (cache_hit && include_patch) ? patch_limit : NH_MAX_REVS, + .since = since, + .until = until, + }; + fresh_count = + nh_run_git_log(root_path, node.file_path, track_start, track_end, &opts, &fresh); if (fresh_count < 0) { + const char *msg = + wt_overlap ? "git log failed for this range — the symbol has uncommitted edits; " + "history is only available once its lines exist at HEAD" + : "git log failed for this range — the index may be stale (file " + "changed since last index). Re-run index_repository and retry."; + if (fresh_count == NH_GIT_TIMEOUT) { + msg = "git log timed out — the history walk is too expensive for this range. " + "Bound it with since/until (e.g. since=\"6 months ago\") and retry."; + } + free(since); + free(until); free(root_path); free_node_contents(&node); free(qn); free(project); - return cbm_mcp_text_result( - wt_overlap ? "git log failed for this range — the symbol has uncommitted edits; " - "history is only available once its lines exist at HEAD" - : "git log failed for this range — the index may be stale (file " - "changed since last index). Re-run index_repository and retry.", - true); + return cbm_mcp_text_result(msg, true); } /* Persist metadata (sparse: no patch text) — only for full, exact * computations: a patch-limited re-run on a hit must not truncate - * the cached timeline, and an overlap-approximated range must not - * poison the entry served for a later clean tree at the same HEAD. */ - if (!cache_hit && !wt_overlap) { + * the cached timeline, an overlap-approximated range must not + * poison the entry served for a later clean tree at the same HEAD, + * and a windowed walk is partial by construction. */ + if (!cache_hit && !wt_overlap && !windowed) { cbm_node_revision_t *rows = calloc(fresh_count ? fresh_count : SKIP_ONE, sizeof(*rows)); if (rows) { @@ -4800,6 +4923,8 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { if (cache_hit && cbm_store_get_node_revisions(store, effective_project, node.qualified_name, &revs, &rev_count) != CBM_STORE_OK) { nh_free_revs(fresh, fresh_count); + free(since); + free(until); free(root_path); free_node_contents(&node); free(qn); @@ -4817,6 +4942,17 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, root_obj, "end_line", node.end_line); yyjson_mut_obj_add_strcpy(doc, root_obj, "cache", cache_hit ? "hit" : (wt_modified ? "computed_dirty" : "computed")); + if (repo_shallow) { + /* Shallow clone: the walk stops at the graft boundary, so the + * timeline is truncated by clone depth, not real history. */ + yyjson_mut_obj_add_bool(doc, root_obj, "shallow", true); + } + if (since) { + yyjson_mut_obj_add_strcpy(doc, root_obj, "since", since); + } + if (until) { + yyjson_mut_obj_add_strcpy(doc, root_obj, "until", until); + } if (wt_modified) { /* Uncommitted edits: indexed lines were remapped to HEAD coords. */ yyjson_mut_obj_add_int(doc, root_obj, "tracked_start_at_head", track_start); @@ -4873,6 +5009,8 @@ static char *handle_get_node_history(cbm_mcp_server_t *srv, const char *args) { cbm_store_free_node_revisions(revs, rev_count); nh_free_revs(fresh, fresh_count); + free(since); + free(until); free(root_path); free_node_contents(&node); free(qn); diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 72d20b1bb..e22b5b9de 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -130,6 +130,10 @@ void cbm_range_map_init(cbm_range_map_t *m, int start, int end); void cbm_range_map_hunk(cbm_range_map_t *m, const char *diff_line); void cbm_range_map_finish(cbm_range_map_t *m); +/* Validate a user-supplied git date for safe embedding in a quoted shell + * command (since/until args of get_node_history). Exposed for unit tests. */ +bool cbm_nh_valid_git_date(const char *s); + /* ── Idle store eviction ──────────────────────────────────────── */ /* Evict the cached project store if idle for more than timeout_s seconds. diff --git a/tests/test_node_history.c b/tests/test_node_history.c index 43d1cf2bc..705d57333 100644 --- a/tests/test_node_history.c +++ b/tests/test_node_history.c @@ -6,6 +6,8 @@ #include "test_framework.h" #include #include +#include +#include #include #include @@ -273,6 +275,85 @@ TEST(rangemap_partial_overlap_is_not_uncommitted) { PASS(); } +/* ── since/until shell-safety validation ─────────────────────── */ + +TEST(gitdate_accepts_common_forms) { + ASSERT_TRUE(cbm_nh_valid_git_date("2026-01-01")); + ASSERT_TRUE(cbm_nh_valid_git_date("6 months ago")); + ASSERT_TRUE(cbm_nh_valid_git_date("2026-01-01T00:00:00+08:00")); + ASSERT_TRUE(cbm_nh_valid_git_date("1.week.ago")); + ASSERT_TRUE(cbm_nh_valid_git_date("2026/01/01")); + PASS(); +} + +TEST(gitdate_rejects_shell_metacharacters) { + ASSERT_FALSE(cbm_nh_valid_git_date(NULL)); + ASSERT_FALSE(cbm_nh_valid_git_date("")); + ASSERT_FALSE(cbm_nh_valid_git_date("'; rm -rf ~")); + ASSERT_FALSE(cbm_nh_valid_git_date("$(reboot)")); + ASSERT_FALSE(cbm_nh_valid_git_date("`id`")); + ASSERT_FALSE(cbm_nh_valid_git_date("a;b")); + ASSERT_FALSE(cbm_nh_valid_git_date("a|b")); + ASSERT_FALSE(cbm_nh_valid_git_date("a>b")); + ASSERT_FALSE(cbm_nh_valid_git_date("it's")); + ASSERT_FALSE(cbm_nh_valid_git_date("2026-01-01 12:00 \"quoted\"")); + /* over the 64-char cap */ + ASSERT_FALSE(cbm_nh_valid_git_date("0123456789012345678901234567890123456789" + "0123456789012345678901234")); + PASS(); +} + +/* ── Deadline-bounded command reader (POSIX-only assertions) ─── */ + +TEST(proc_reads_lines_then_exit_status) { +#ifndef _WIN32 + cbm_proc_t p; + ASSERT_TRUE(cbm_proc_open(&p, "printf 'one\\ntwo\\n'; exit 7", 5000)); + char line[CBM_SZ_64]; + ASSERT_TRUE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_STR_EQ(line, "one\n"); + ASSERT_TRUE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_STR_EQ(line, "two\n"); + ASSERT_FALSE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_FALSE(p.timed_out); + ASSERT_EQ(cbm_proc_close(&p), 7); +#endif + PASS(); +} + +TEST(proc_long_line_split_fgets_style) { +#ifndef _WIN32 + cbm_proc_t p; + ASSERT_TRUE(cbm_proc_open(&p, "printf 'abcdef\\n'", 5000)); + char line[4]; /* cap-1 = 3 payload bytes per read */ + ASSERT_TRUE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_STR_EQ(line, "abc"); + ASSERT_TRUE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_STR_EQ(line, "def"); + ASSERT_TRUE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_STR_EQ(line, "\n"); + ASSERT_FALSE(cbm_proc_gets(&p, line, sizeof(line))); + cbm_proc_close(&p); +#endif + PASS(); +} + +TEST(proc_deadline_kills_stalled_child) { +#ifndef _WIN32 + cbm_proc_t p; + /* Child emits one line then stalls well past the 200ms deadline. */ + ASSERT_TRUE(cbm_proc_open(&p, "printf 'first\\n'; sleep 30", 200)); + char line[CBM_SZ_64]; + ASSERT_TRUE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_STR_EQ(line, "first\n"); + ASSERT_FALSE(cbm_proc_gets(&p, line, sizeof(line))); + ASSERT_TRUE(p.timed_out); + /* close must kill the child and return promptly, not wait 30s. */ + ASSERT_EQ(cbm_proc_close(&p), -1); +#endif + PASS(); +} + SUITE(node_history) { RUN_TEST(noderev_get_on_fresh_store_is_empty); RUN_TEST(noderev_head_empty_when_uncached); @@ -291,4 +372,9 @@ SUITE(node_history) { RUN_TEST(rangemap_start_clamped_to_one); RUN_TEST(rangemap_pure_insertion_covering_range_is_uncommitted); RUN_TEST(rangemap_partial_overlap_is_not_uncommitted); + RUN_TEST(gitdate_accepts_common_forms); + RUN_TEST(gitdate_rejects_shell_metacharacters); + RUN_TEST(proc_reads_lines_then_exit_status); + RUN_TEST(proc_long_line_split_fgets_style); + RUN_TEST(proc_deadline_kills_stalled_child); } From f75c61d8b2f3e26fe5c6afe71d0f73bb274fa614 Mon Sep 17 00:00:00 2001 From: RiceTooCold Date: Sun, 12 Jul 2026 15:37:14 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(mcp):=20co=5Fchanged=20self-filter=20?= =?UTF-8?q?=E2=80=94=20match=20repo-relative=20vs=20index-relative=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git show reports paths relative to the git root, but the node's file path is relative to the indexed root, which may be a subdirectory ("src/" of a monorepo) — the exact-match self-filter never fired there and the symbol's own file topped its co-change list. Suffix match with a '/' boundary. Found dogfooding against a Next.js repo indexed at src/. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019c3No8anx3TLVa2n5uBAnC --- src/mcp/mcp.c | 15 ++++++++++++++- src/mcp/mcp.h | 5 +++++ tests/test_node_history.c | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index fa5a29ba3..da2ac2c36 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4231,6 +4231,19 @@ bool cbm_nh_valid_git_date(const char *s) { return true; } +/* True when a git-reported repo-relative path names the node's file. + * The indexed root may sit below the git root ("src/" of a monorepo), so + * node_file ("lib/a.ts") can be a proper path suffix of the git path + * ("src/lib/a.ts") — require a '/' boundary so "mylib/a.ts" won't match. */ +bool cbm_nh_same_file(const char *git_path, const char *node_file) { + size_t gl = strlen(git_path); + size_t nl = strlen(node_file); + if (gl < nl || strcmp(git_path + (gl - nl), node_file) != 0) { + return false; + } + return gl == nl || git_path[gl - nl - SKIP_ONE] == '/'; +} + /* One parsed revision. patch is heap-allocated only when capturing diffs. */ typedef struct { char sha[NH_SHA_BUF]; @@ -4623,7 +4636,7 @@ static void nh_add_co_changed(yyjson_mut_doc *doc, yyjson_mut_val *arr, const ch if (len == 0 || strncmp(line, "@@C@@", SLEN("@@C@@")) == 0) { continue; } - if (strcmp(line, node_file) == 0) { + if (cbm_nh_same_file(line, node_file)) { continue; } int idx = -1; diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index e22b5b9de..bf3c203df 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -134,6 +134,11 @@ void cbm_range_map_finish(cbm_range_map_t *m); * command (since/until args of get_node_history). Exposed for unit tests. */ bool cbm_nh_valid_git_date(const char *s); +/* True when a git-reported repo-relative path names the node's + * index-relative file (suffix match with '/' boundary — the indexed root + * may sit below the git root). Exposed for unit tests. */ +bool cbm_nh_same_file(const char *git_path, const char *node_file); + /* ── Idle store eviction ──────────────────────────────────────── */ /* Evict the cached project store if idle for more than timeout_s seconds. diff --git a/tests/test_node_history.c b/tests/test_node_history.c index 705d57333..8cf3bb399 100644 --- a/tests/test_node_history.c +++ b/tests/test_node_history.c @@ -303,6 +303,17 @@ TEST(gitdate_rejects_shell_metacharacters) { PASS(); } +TEST(cochange_self_filter_handles_subdir_index_root) { + /* git show reports repo-relative paths; the node's path is relative + * to the indexed root, which may be a subdir of the git root. */ + ASSERT_TRUE(cbm_nh_same_file("lib/a.ts", "lib/a.ts")); + ASSERT_TRUE(cbm_nh_same_file("src/lib/a.ts", "lib/a.ts")); + ASSERT_FALSE(cbm_nh_same_file("mylib/a.ts", "lib/a.ts")); /* boundary */ + ASSERT_FALSE(cbm_nh_same_file("lib/b.ts", "lib/a.ts")); + ASSERT_FALSE(cbm_nh_same_file("a.ts", "lib/a.ts")); + PASS(); +} + /* ── Deadline-bounded command reader (POSIX-only assertions) ─── */ TEST(proc_reads_lines_then_exit_status) { @@ -374,6 +385,7 @@ SUITE(node_history) { RUN_TEST(rangemap_partial_overlap_is_not_uncommitted); RUN_TEST(gitdate_accepts_common_forms); RUN_TEST(gitdate_rejects_shell_metacharacters); + RUN_TEST(cochange_self_filter_handles_subdir_index_root); RUN_TEST(proc_reads_lines_then_exit_status); RUN_TEST(proc_long_line_split_fgets_style); RUN_TEST(proc_deadline_kills_stalled_child);