mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #1561 from tmonestudio/codex/bt-240-truthful-index-freshness
feat(mcp): report truthful index freshness from checkout evidence
This commit is contained in:
12 files changed
+1424
-10
No files matched your search
@@ -3,6 +3,7 @@
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/mem_core.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/str_util.h"
|
||||
|
||||
@@ -17,6 +18,17 @@ enum {
|
||||
GIT_OUTPUT_MAX = 4096,
|
||||
};
|
||||
|
||||
/* Bounded worktree-status stream constants.
|
||||
* STATUS_FIELD_MAX is the largest single NUL-delimited record field we
|
||||
* buffer; a field at/over the bound is treated as a malformed stream and the
|
||||
* whole snapshot fails closed. PATH components of real repos are far below
|
||||
* this even with long paths. */
|
||||
enum {
|
||||
STATUS_HEADER_LEN = 3, /* porcelain-v1 record prefix "XY " */
|
||||
STATUS_CHUNK_MAX = CBM_SZ_4K,
|
||||
STATUS_FIELD_MAX = CBM_SZ_16K,
|
||||
};
|
||||
|
||||
static char *git_strdup(const char *s) {
|
||||
if (!s) {
|
||||
s = "";
|
||||
@@ -641,3 +653,210 @@ int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
/* ── Bounded worktree status ──────────────────────────────────────────────
|
||||
* git status --porcelain=v1 -z --untracked-files=all is parsed record by
|
||||
* record, NUL-delimited, over a bounded chunk buffer. The full stream is
|
||||
* counted but only max_samples paths per class (tracked / untracked) are
|
||||
* ever retained, so a huge dirty tree cannot grow memory. Rename/copy
|
||||
* records carry a second NUL-terminated path (the source); it is consumed
|
||||
* without being counted so one rename counts once. */
|
||||
|
||||
typedef struct {
|
||||
cbm_worktree_status_t *out;
|
||||
int max_samples;
|
||||
bool expecting_source; /* next field is the source path of an R/C record */
|
||||
bool parse_ok;
|
||||
char partial[STATUS_FIELD_MAX];
|
||||
size_t partial_len;
|
||||
} status_parser_t;
|
||||
|
||||
/* porcelain-v1 status columns (X/Y): documented codes plus the lowercase
|
||||
* submodule variants and type-change 'T'. Anything else is not a record
|
||||
* header, so a malformed stream fails closed instead of being misread. */
|
||||
static bool status_code_ok(char c) {
|
||||
switch (c) {
|
||||
case ' ':
|
||||
case 'M':
|
||||
case 'A':
|
||||
case 'D':
|
||||
case 'R':
|
||||
case 'C':
|
||||
case 'U':
|
||||
case '?':
|
||||
case '!':
|
||||
case 'T':
|
||||
case 'm':
|
||||
case 'a':
|
||||
case 'd':
|
||||
case 'r':
|
||||
case 'c':
|
||||
case 'u':
|
||||
case 't':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* Retain one bounded sample; the running total always advances so the caller
|
||||
* sees the true stream count even when samples are capped or OOM. */
|
||||
static void status_add_sample(int *total, char ***samples, int *sample_count, bool *truncated,
|
||||
int max_samples, const char *path, size_t path_len) {
|
||||
(*total)++;
|
||||
if (*sample_count >= max_samples) {
|
||||
*truncated = true;
|
||||
return;
|
||||
}
|
||||
char *copy = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, path_len + 1);
|
||||
if (!copy) {
|
||||
*truncated = true;
|
||||
return;
|
||||
}
|
||||
memcpy(copy, path, path_len);
|
||||
copy[path_len] = '\0';
|
||||
char **grown = (char **)cbm_realloc(CBM_MEM_CLASS_OTHER, *samples,
|
||||
(size_t)(*sample_count + 1) * sizeof(char *));
|
||||
if (!grown) {
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, copy);
|
||||
*truncated = true;
|
||||
return;
|
||||
}
|
||||
*samples = grown;
|
||||
(*samples)[*sample_count] = copy;
|
||||
(*sample_count)++;
|
||||
}
|
||||
|
||||
/* Parse one complete NUL-delimited field. Returns false on malformed input,
|
||||
* which fails the whole snapshot closed. */
|
||||
static bool status_parse_record(cbm_worktree_status_t *out, status_parser_t *parser,
|
||||
const char *field, size_t field_len) {
|
||||
if (parser->expecting_source) {
|
||||
parser->expecting_source = false;
|
||||
return true; /* second path of a rename/copy record — not counted */
|
||||
}
|
||||
if (field_len < STATUS_HEADER_LEN || field[2] != ' ' || !status_code_ok(field[0]) ||
|
||||
!status_code_ok(field[1])) {
|
||||
return false;
|
||||
}
|
||||
const char *path = field + STATUS_HEADER_LEN;
|
||||
size_t path_len = field_len - STATUS_HEADER_LEN;
|
||||
if (field[0] == '?' && field[1] == '?') {
|
||||
status_add_sample(&out->untracked_count, &out->untracked_paths,
|
||||
&out->untracked_sample_count, &out->untracked_truncated,
|
||||
parser->max_samples, path, path_len);
|
||||
return true;
|
||||
}
|
||||
if (field[0] == 'R' || field[0] == 'C' || field[1] == 'R' || field[1] == 'C') {
|
||||
parser->expecting_source = true;
|
||||
}
|
||||
status_add_sample(&out->tracked_count, &out->tracked_paths, &out->tracked_sample_count,
|
||||
&out->tracked_truncated, parser->max_samples, path, path_len);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Feed a chunk of the NUL-delimited stream through the bounded record parser. */
|
||||
static void status_feed(status_parser_t *parser, const char *data, size_t data_len) {
|
||||
size_t i = 0;
|
||||
while (i < data_len) {
|
||||
if (parser->partial_len >= STATUS_FIELD_MAX) {
|
||||
parser->parse_ok = false; /* field exceeded the bound: malformed */
|
||||
return;
|
||||
}
|
||||
size_t j = i;
|
||||
while (j < data_len && data[j] != '\0' &&
|
||||
parser->partial_len + (j - i) < STATUS_FIELD_MAX) {
|
||||
j++;
|
||||
}
|
||||
size_t take = j - i;
|
||||
memcpy(parser->partial + parser->partial_len, data + i, take);
|
||||
parser->partial_len += take;
|
||||
i += take;
|
||||
if (i < data_len && data[i] == '\0') {
|
||||
if (!status_parse_record(parser->out, parser, parser->partial, parser->partial_len)) {
|
||||
parser->parse_ok = false;
|
||||
return;
|
||||
}
|
||||
parser->partial_len = 0;
|
||||
i++; /* consume the record terminator */
|
||||
} else if (i >= data_len) {
|
||||
return; /* chunk exhausted mid-record */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int cbm_git_worktree_status(const char *validated_root, int max_samples,
|
||||
cbm_worktree_status_t *out) {
|
||||
if (!out) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!validated_root || !validated_root[0] || max_samples < 0) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
if (!git_validate_repo_path(validated_root)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
char cmd[GIT_CMD_MAX];
|
||||
#ifdef _WIN32
|
||||
const char *null_dev = "NUL";
|
||||
#else
|
||||
const char *null_dev = "/dev/null";
|
||||
#endif
|
||||
int n = snprintf(cmd, sizeof(cmd),
|
||||
"git -C \"%s\" status --porcelain=v1 -z --untracked-files=all 2>%s",
|
||||
validated_root, null_dev);
|
||||
if (n < 0 || n >= (int)sizeof(cmd)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* An inherited GIT_DIR/GIT_WORK_TREE (a git hook, an editor) overrides
|
||||
* `git -C`, which would report another repository's worktree here. */
|
||||
FILE *fp = cbm_popen_git(cmd);
|
||||
if (!fp) {
|
||||
return 0; /* out is zeroed: available=false */
|
||||
}
|
||||
|
||||
status_parser_t parser;
|
||||
memset(&parser, 0, sizeof(parser));
|
||||
parser.out = out;
|
||||
parser.max_samples = max_samples;
|
||||
parser.parse_ok = true;
|
||||
|
||||
char chunk[STATUS_CHUNK_MAX];
|
||||
size_t got;
|
||||
bool io_error = false;
|
||||
while (parser.parse_ok && (got = fread(chunk, 1, sizeof(chunk), fp)) > 0) {
|
||||
status_feed(&parser, chunk, got);
|
||||
}
|
||||
if (ferror(fp)) {
|
||||
io_error = true;
|
||||
}
|
||||
int rc = cbm_pclose(fp);
|
||||
|
||||
if (rc != 0 || !parser.parse_ok || io_error || parser.partial_len > 0) {
|
||||
/* Fail closed: any deviation from a clean, complete status stream is a
|
||||
* machine-readable "unavailable", never a clean worktree. */
|
||||
cbm_git_worktree_status_free(out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
out->available = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void cbm_git_worktree_status_free(cbm_worktree_status_t *st) {
|
||||
if (!st) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < st->tracked_sample_count; i++) {
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths[i]);
|
||||
}
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths);
|
||||
for (int i = 0; i < st->untracked_sample_count; i++) {
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths[i]);
|
||||
}
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths);
|
||||
memset(st, 0, sizeof(*st));
|
||||
}
|
||||
@@ -50,4 +50,40 @@ void cbm_git_context_free(cbm_git_context_t *ctx);
|
||||
char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx);
|
||||
int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size);
|
||||
|
||||
/* Bounded snapshot of the git worktree status for one validated repo root.
|
||||
*
|
||||
* available is true ONLY when `git status --porcelain=v1 -z --untracked-files=all`
|
||||
* exited 0 AND every NUL-delimited record parsed cleanly. Any other outcome —
|
||||
* shell-unsafe path, spawn failure, nonzero exit, malformed/oversized record,
|
||||
* trailing partial record — leaves available=false with zero counts and no
|
||||
* samples. Callers MUST treat !available as "status unavailable", never as a
|
||||
* clean worktree.
|
||||
*
|
||||
* The entire stream is counted, but only max_samples paths per class (tracked
|
||||
* / untracked) are ever retained, in stream order; exceeding the cap sets the
|
||||
* matching *_truncated flag. Rename/copy records are counted once (their two
|
||||
* NUL-separated paths are consumed without counting the second), and the
|
||||
* sampled path is the record's NEW path. Ignored files never appear because
|
||||
* --ignored is not requested.
|
||||
*
|
||||
* cbm_git_worktree_status returns 0 when *out has been filled (available tells
|
||||
* whether the snapshot succeeded) and CBM_NOT_FOUND for invalid arguments
|
||||
* (NULL root/out or max_samples < 0). The caller owns the returned samples via
|
||||
* cbm_git_worktree_status_free. */
|
||||
typedef struct {
|
||||
bool available;
|
||||
int tracked_count;
|
||||
int untracked_count;
|
||||
char **tracked_paths; /* heap-owned samples, tracked_sample_count entries */
|
||||
int tracked_sample_count;
|
||||
bool tracked_truncated;
|
||||
char **untracked_paths;
|
||||
int untracked_sample_count;
|
||||
bool untracked_truncated;
|
||||
} cbm_worktree_status_t;
|
||||
|
||||
int cbm_git_worktree_status(const char *validated_root, int max_samples,
|
||||
cbm_worktree_status_t *out);
|
||||
void cbm_git_worktree_status_free(cbm_worktree_status_t *st);
|
||||
|
||||
#endif
|
||||
+163
-3
@@ -49,6 +49,8 @@ enum {
|
||||
* promise. The actual cross-platform contract is this deterministic UTF-8
|
||||
* byte ceiling, applied only at whole semantic-unit boundaries. */
|
||||
MCP_OUTPUT_BYTES_PER_TOKEN_ESTIMATE = 4,
|
||||
MCP_STATUS_SAMPLE_MAX = 16, /* per-class path samples in verbose freshness */
|
||||
MCP_REASONS_MAX = 8, /* freshness reasons array ceiling (see below) */
|
||||
};
|
||||
#define MCP_MS_TO_US 1000LL
|
||||
#define MCP_S_TO_US 1000000LL
|
||||
@@ -7792,13 +7794,169 @@ static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yy
|
||||
yyjson_mut_obj_add_val(doc, root, "watch", watch);
|
||||
}
|
||||
|
||||
/* Emit the bounded worktree-status snapshot inside the freshness block.
|
||||
* Fail-closed: status_available is false whenever git status could not run
|
||||
* clean, in which case the API guarantees zero counts and no samples — a
|
||||
* non-git root or missing git is NEVER presented as a clean worktree. Ignored
|
||||
* paths are deliberately absent (no --ignored in the underlying query) and
|
||||
* must not be confused with tracked/untracked sources. */
|
||||
static void add_worktree_status_json(yyjson_mut_doc *doc, yyjson_mut_val *freshness,
|
||||
const cbm_worktree_status_t *st) {
|
||||
yyjson_mut_obj_add_bool(doc, freshness, "status_available", st->available);
|
||||
|
||||
yyjson_mut_val *tracked = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_int(doc, tracked, "count", st->tracked_count);
|
||||
yyjson_mut_val *tpaths = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < st->tracked_sample_count; i++) {
|
||||
yyjson_mut_arr_add_strcpy(doc, tpaths, st->tracked_paths[i]);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, tracked, "paths", tpaths);
|
||||
yyjson_mut_obj_add_bool(doc, tracked, "truncated", st->tracked_truncated);
|
||||
yyjson_mut_obj_add_val(doc, freshness, "tracked_changes", tracked);
|
||||
|
||||
yyjson_mut_val *untracked = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_int(doc, untracked, "count", st->untracked_count);
|
||||
yyjson_mut_val *upaths = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < st->untracked_sample_count; i++) {
|
||||
yyjson_mut_arr_add_strcpy(doc, upaths, st->untracked_paths[i]);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, untracked, "paths", upaths);
|
||||
yyjson_mut_obj_add_bool(doc, untracked, "truncated", st->untracked_truncated);
|
||||
yyjson_mut_obj_add_val(doc, freshness, "untracked_source", untracked);
|
||||
}
|
||||
|
||||
/* BT-240: fail-closed freshness verdict against the indexed-checkout identity
|
||||
* recorded with the DB at the successful staged-generation boundary. A live
|
||||
* checkout SHA is not proof of the generation that produced graph content, so
|
||||
* the verdict comes from that recorded identity, the live git HEAD and the
|
||||
* bounded worktree status (tracked/untracked deltas):
|
||||
* - no indexed SHA → unknown / unavailable
|
||||
* - indexed SHA differs from the live git HEAD → stale / mismatch
|
||||
* - tracked changes present → stale / tracked
|
||||
* - untracked sources present → prevents current
|
||||
* - equal SHA + status available + no changes → current
|
||||
* Report-only: it never triggers indexing. Verbose-only and read-only; the
|
||||
* live checkout is resolved fresh from the project root (never mutated). */
|
||||
static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_store_t *store,
|
||||
const char *project, const char *root_path,
|
||||
const char *indexed_generation) {
|
||||
yyjson_mut_val *freshness = yyjson_mut_obj(doc);
|
||||
if (indexed_generation && indexed_generation[0]) {
|
||||
yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_generation", indexed_generation);
|
||||
} else {
|
||||
yyjson_mut_obj_add_str(doc, freshness, "indexed_generation", "");
|
||||
}
|
||||
|
||||
cbm_coverage_meta_t meta = {0};
|
||||
bool have_meta = store && cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK;
|
||||
const char *indexed_sha = NULL;
|
||||
if (have_meta && meta.indexed_checkout_sha && meta.indexed_checkout_sha[0]) {
|
||||
indexed_sha = meta.indexed_checkout_sha;
|
||||
}
|
||||
|
||||
cbm_git_context_t ctx = {0};
|
||||
(void)cbm_git_context_resolve(root_path, &ctx);
|
||||
const char *checkout_sha = ctx.head_sha && ctx.head_sha[0] ? ctx.head_sha : NULL;
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int status_rc = cbm_git_worktree_status(root_path, MCP_STATUS_SAMPLE_MAX, &st);
|
||||
bool status_available = status_rc == 0 && st.available;
|
||||
add_worktree_status_json(doc, freshness, &st);
|
||||
|
||||
if (indexed_sha) {
|
||||
yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_checkout_sha", indexed_sha);
|
||||
} else {
|
||||
yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha");
|
||||
}
|
||||
if (checkout_sha) {
|
||||
yyjson_mut_obj_add_strcpy(doc, freshness, "checkout_sha", checkout_sha);
|
||||
} else {
|
||||
yyjson_mut_obj_add_null(doc, freshness, "checkout_sha");
|
||||
}
|
||||
|
||||
/* Verdict/reasons composition. Stale reasons (identity mismatch, tracked
|
||||
* changes) win over everything; a missing indexed identity is unknown
|
||||
* regardless of live state; status unavailability and untracked sources
|
||||
* both prevent "current" but only yield stale when a stale reason is
|
||||
* already present. The reasons array keeps every applicable code in a
|
||||
* stable order; the singular reason stays as the first (dominant) one for
|
||||
* compatibility with consumers that read it directly. */
|
||||
bool sha_present = indexed_sha != NULL;
|
||||
bool live_present = checkout_sha != NULL;
|
||||
bool sha_mismatch = sha_present && live_present && strcmp(indexed_sha, checkout_sha) != 0;
|
||||
bool has_tracked = st.tracked_count > 0;
|
||||
bool has_untracked = st.untracked_count > 0;
|
||||
bool has_stale = sha_mismatch || has_tracked;
|
||||
|
||||
const char *verdict;
|
||||
if (!sha_present) {
|
||||
verdict = "unknown";
|
||||
} else if (has_stale) {
|
||||
verdict = "stale";
|
||||
} else if (!live_present) {
|
||||
verdict = "unknown";
|
||||
} else if (!status_available) {
|
||||
verdict = "unknown";
|
||||
} else if (has_untracked) {
|
||||
verdict = "unknown";
|
||||
} else {
|
||||
verdict = "current";
|
||||
}
|
||||
|
||||
const char *reasons[MCP_REASONS_MAX];
|
||||
int n_reasons = 0;
|
||||
if (!sha_present) {
|
||||
reasons[n_reasons++] = "indexed_checkout_unavailable";
|
||||
}
|
||||
if (sha_mismatch) {
|
||||
reasons[n_reasons++] = "indexed_checkout_mismatch";
|
||||
}
|
||||
if (has_tracked) {
|
||||
reasons[n_reasons++] = "tracked_changes_present";
|
||||
}
|
||||
if (!status_available) {
|
||||
reasons[n_reasons++] = "status_unavailable";
|
||||
}
|
||||
if (has_untracked) {
|
||||
reasons[n_reasons++] = "untracked_not_indexed";
|
||||
}
|
||||
if (n_reasons == 0) {
|
||||
reasons[n_reasons++] = "indexed_checkout_current";
|
||||
}
|
||||
const char *reason = reasons[0];
|
||||
|
||||
const char *recommended_action;
|
||||
if (strcmp(verdict, "current") == 0) {
|
||||
recommended_action = "use_graph";
|
||||
} else if (strcmp(verdict, "stale") == 0) {
|
||||
recommended_action = "reindex_to_match_checkout";
|
||||
} else {
|
||||
recommended_action = "reindex_to_record_indexed_checkout";
|
||||
}
|
||||
yyjson_mut_obj_add_str(doc, freshness, "verdict", verdict);
|
||||
yyjson_mut_obj_add_str(doc, freshness, "reason", reason);
|
||||
yyjson_mut_val *reasons_val = yyjson_mut_arr(doc);
|
||||
for (int i = 0; i < n_reasons; i++) {
|
||||
yyjson_mut_arr_add_str(doc, reasons_val, reasons[i]);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons_val);
|
||||
yyjson_mut_obj_add_str(doc, freshness, "recommended_action", recommended_action);
|
||||
yyjson_mut_obj_add_val(doc, root, "freshness", freshness);
|
||||
|
||||
if (have_meta) {
|
||||
cbm_store_coverage_meta_clear(&meta);
|
||||
}
|
||||
cbm_git_worktree_status_free(&st);
|
||||
cbm_git_context_free(&ctx);
|
||||
}
|
||||
|
||||
static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
|
||||
char *project = get_project_arg(args);
|
||||
cbm_store_t *store = resolve_store(srv, project);
|
||||
REQUIRE_STORE(store, project);
|
||||
/* The git context block (worktree/shadow path variants) only matters when
|
||||
* debugging index-location issues — gate it so the common status call
|
||||
* stays lean. */
|
||||
/* The git context block (worktree/shadow path variants) and the freshness
|
||||
* verdict only matter when debugging index-location issues — gate them so
|
||||
* the common status call stays lean. */
|
||||
bool verbose = cbm_mcp_get_bool_arg(args, "verbose");
|
||||
char *diagnostics = cbm_mcp_get_string_arg(args, "diagnostics");
|
||||
int coverage_samples = 0;
|
||||
@@ -7843,6 +8001,8 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
|
||||
proj_info.indexed_at ? proj_info.indexed_at : "");
|
||||
if (verbose) {
|
||||
add_git_context_json(doc, root, proj_info.root_path);
|
||||
add_index_freshness_json(doc, root, store, project, proj_info.root_path,
|
||||
proj_info.indexed_at);
|
||||
}
|
||||
}
|
||||
add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL,
|
||||
|
||||
@@ -1631,6 +1631,10 @@ cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t *
|
||||
return p ? &p->resource_violation : NULL;
|
||||
}
|
||||
|
||||
const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p) {
|
||||
return p ? p->git_ctx.head_sha : NULL;
|
||||
}
|
||||
|
||||
atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p) {
|
||||
return p ? p->cancelled : NULL;
|
||||
}
|
||||
@@ -4165,6 +4169,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas
|
||||
.hash_records_complete = true,
|
||||
.unresolved_calls_complete =
|
||||
pipeline_unresolved_capture_complete(p, coverage_rows_available),
|
||||
.indexed_checkout_sha = p->git_ctx.head_sha,
|
||||
},
|
||||
.surface_rows = p->surface_rows,
|
||||
.surface_row_count = p->surface_row_count,
|
||||
|
||||
@@ -3858,6 +3858,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
|
||||
.hash_records_complete = true,
|
||||
.unresolved_calls_complete = incremental_capture_complete(
|
||||
coverage_rows_available, unresolved_complete, retained_unresolved_complete),
|
||||
.indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p),
|
||||
},
|
||||
.surface_rows = NULL,
|
||||
.surface_row_count = 0,
|
||||
@@ -4543,6 +4544,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
.hash_records_complete = true,
|
||||
.unresolved_calls_complete = incremental_capture_complete(
|
||||
coverage_rows_available, unresolved_complete, retained_unresolved_complete),
|
||||
.indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p),
|
||||
};
|
||||
/* Publish surfaces: the surviving previous rows plus this run's fresh
|
||||
* ones (closure route). The legacy test route publishes none — its
|
||||
|
||||
@@ -1883,6 +1883,8 @@ const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline
|
||||
bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, const cbm_userconfig_t *fresh);
|
||||
const cbm_index_resource_policy_t *cbm_pipeline_resource_policy(const cbm_pipeline_t *p);
|
||||
cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t *p);
|
||||
/* Borrowed checkout identity captured and validated for this pipeline run. */
|
||||
const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p);
|
||||
atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p);
|
||||
/* Record committed graph size (#334 gate axis) from the incremental path,
|
||||
* which cannot see the opaque cbm_pipeline struct. Call before the dump. */
|
||||
|
||||
+96
-3
@@ -340,6 +340,26 @@ bool cbm_store_edges_schema_current(cbm_store_t *s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Best-effort probe for the BT-240 index_checkout table. Writable opens run
|
||||
* init_schema (which creates it), so false is expected only on read-only
|
||||
* legacy DBs opened via the query path — those must keep working and simply
|
||||
* report no indexed-checkout identity. */
|
||||
static bool index_checkout_table_exists(cbm_store_t *s) {
|
||||
if (!s || !s->db) {
|
||||
return false;
|
||||
}
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db,
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name = 'index_checkout';",
|
||||
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
bool has = sqlite3_step(stmt) == SQLITE_ROW;
|
||||
sqlite3_finalize(stmt);
|
||||
return has;
|
||||
}
|
||||
|
||||
static int init_schema(cbm_store_t *s) {
|
||||
const char *ddl =
|
||||
"CREATE TABLE IF NOT EXISTS projects ("
|
||||
@@ -495,6 +515,14 @@ static int init_schema(cbm_store_t *s) {
|
||||
" coverage_version INTEGER NOT NULL DEFAULT 1,"
|
||||
" hash_records_complete INTEGER NOT NULL DEFAULT 0,"
|
||||
" unresolved_calls_complete INTEGER NOT NULL DEFAULT 0"
|
||||
");"
|
||||
/* BT-240: the checkout an index was built from. A table of its own,
|
||||
* not an index_coverage_meta column: it is source identity, which the
|
||||
* pinned graph-digest tables deliberately do not bind, and a new
|
||||
* table needs no migration of existing databases. */
|
||||
"CREATE TABLE IF NOT EXISTS index_checkout ("
|
||||
" project TEXT PRIMARY KEY REFERENCES projects(name) ON DELETE CASCADE,"
|
||||
" indexed_checkout_sha TEXT NOT NULL"
|
||||
");";
|
||||
|
||||
int rc = exec_sql(s, ddl);
|
||||
@@ -3090,6 +3118,7 @@ int cbm_store_delete_project(cbm_store_t *s, const char *name) {
|
||||
static const char *cleanup_sql[] = {
|
||||
"DELETE FROM index_coverage WHERE project = ?1;",
|
||||
"DELETE FROM index_coverage_meta WHERE project = ?1;",
|
||||
"DELETE FROM index_checkout WHERE project = ?1;",
|
||||
"DELETE FROM projects WHERE name = ?1 || '::missed';",
|
||||
};
|
||||
/* doc_link_unresolved exists only in databases published by a build that
|
||||
@@ -4838,6 +4867,34 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) {
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
/* Record (or, with no sha, clear) the checkout a project's index was built
|
||||
* from. Runs inside the caller's coverage transaction, so a failed replace
|
||||
* keeps the previous identity. */
|
||||
static int index_checkout_write(cbm_store_t *s, const char *project, const char *sha) {
|
||||
bool record = sha && sha[0];
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db,
|
||||
record ? "INSERT INTO index_checkout (project, indexed_checkout_sha) "
|
||||
"VALUES (?1, ?2) ON CONFLICT(project) DO UPDATE SET "
|
||||
"indexed_checkout_sha=?2;"
|
||||
: "DELETE FROM index_checkout WHERE project = ?1;",
|
||||
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "index checkout write prepare");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bind_text(stmt, SKIP_ONE, project);
|
||||
if (record) {
|
||||
bind_text(stmt, ST_COL_2, sha);
|
||||
}
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
store_set_error_sqlite(s, "index checkout write");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
/* Materialize once at publication, after replacement and deleted-file pruning.
|
||||
* json_each sees a safe marker array for malformed/legacy non-array payloads.
|
||||
* The side table cascades with its authoritative coverage rows. */
|
||||
@@ -4893,7 +4950,7 @@ static int coverage_write_metadata(cbm_store_t *s, const char *project,
|
||||
store_set_error_sqlite(s, "coverage meta delete");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
return index_checkout_write(s, project, NULL);
|
||||
}
|
||||
char recorded_at[CBM_SZ_64];
|
||||
if (meta->recorded_at && meta->recorded_at[0]) {
|
||||
@@ -4943,7 +5000,7 @@ static int coverage_write_metadata(cbm_store_t *s, const char *project,
|
||||
store_set_error_sqlite(s, "coverage meta upsert");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
return index_checkout_write(s, project, meta->indexed_checkout_sha);
|
||||
}
|
||||
|
||||
int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project,
|
||||
@@ -5218,9 +5275,45 @@ void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) {
|
||||
free((char *)meta->index_mode);
|
||||
free((char *)meta->recorded_at);
|
||||
free((char *)meta->recording_status);
|
||||
cbm_free(CBM_MEM_CLASS_STORE, (char *)meta->indexed_checkout_sha);
|
||||
memset(meta, 0, sizeof(*meta));
|
||||
}
|
||||
|
||||
/* Fill out->indexed_checkout_sha from index_checkout. A legacy read-only DB
|
||||
* (query opens skip init_schema) may lack the table: no identity, not an
|
||||
* error. On failure out is cleared. */
|
||||
static int index_checkout_read(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) {
|
||||
if (!index_checkout_table_exists(s)) {
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db,
|
||||
"SELECT indexed_checkout_sha FROM index_checkout WHERE project = ?1;",
|
||||
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "index checkout read prepare");
|
||||
cbm_store_coverage_meta_clear(out);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bind_text(stmt, SKIP_ONE, project);
|
||||
int rc = sqlite3_step(stmt);
|
||||
if (rc == SQLITE_ROW) {
|
||||
const char *sha = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (sha && sha[0]) {
|
||||
out->indexed_checkout_sha = cbm_mem_strdup(CBM_MEM_CLASS_STORE, sha);
|
||||
rc = out->indexed_checkout_sha ? SQLITE_DONE : SQLITE_NOMEM;
|
||||
} else {
|
||||
rc = SQLITE_DONE;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
store_set_error_sqlite(s, "index checkout read");
|
||||
cbm_store_coverage_meta_clear(out);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) {
|
||||
if (!out) {
|
||||
return CBM_STORE_ERR;
|
||||
@@ -5267,7 +5360,7 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag
|
||||
cbm_store_coverage_meta_clear(out);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
return index_checkout_read(s, project, out);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
|
||||
@@ -805,6 +805,11 @@ typedef struct {
|
||||
int coverage_version;
|
||||
bool hash_records_complete;
|
||||
bool unresolved_calls_complete;
|
||||
/* Git checkout identity frozen at the successful staged-generation
|
||||
* boundary (BT-240), stored in the index_checkout table. NULL/"" when the
|
||||
* run had no git context (non-git repo) or the DB predates that table.
|
||||
* Owned by the row: meta_get allocates it, meta_clear frees it. */
|
||||
const char *indexed_checkout_sha;
|
||||
} cbm_coverage_meta_t;
|
||||
|
||||
/* Replace the project's coverage rows in one transaction, then prune rows for
|
||||
|
||||
+317
-1
@@ -10,7 +10,9 @@
|
||||
*
|
||||
* The canonical_root tests shell out to `git`, so they SKIP_PLATFORM on
|
||||
* Windows (the CI shell there cannot init a repo via system()). The root-path
|
||||
* existence test runs on every platform.
|
||||
* existence test runs on every platform. The bounded worktree-status tests
|
||||
* below shell out through cbm_popen instead, so they run on every platform
|
||||
* that has git on PATH.
|
||||
*
|
||||
* Reproduce-first guard: canonical_root_subdir is the genuine RED-without-the-fix
|
||||
* guard — a repo indexed from a subdirectory yields a relative --git-common-dir
|
||||
@@ -646,6 +648,312 @@ TEST(git_branch_resolve_matches_full_context) {
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
/* The freshness verdict reads this snapshot, so an inherited GIT_DIR /
|
||||
* GIT_WORK_TREE pointing at a dirty decoy must not make a clean fixture
|
||||
* report the decoy's changes as its own (#2003). */
|
||||
TEST(worktree_status_ignores_inherited_repo_env) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI");
|
||||
#else
|
||||
char root[256];
|
||||
char *raw = th_mktempdir("cbm_gitenv_status");
|
||||
if (!raw)
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
snprintf(root, sizeof(root), "%s", raw);
|
||||
if (make_env_isolation_tree(root) != 0) {
|
||||
th_rmtree(root);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
char decoy[512], fixture[512], path[600];
|
||||
snprintf(decoy, sizeof(decoy), "%s/decoy", root);
|
||||
snprintf(fixture, sizeof(fixture), "%s/fixture", root);
|
||||
/* Dirty only the decoy: one tracked edit and one untracked file. */
|
||||
snprintf(path, sizeof(path), "%s/a.go", decoy);
|
||||
th_write_file(path, "package a // dirty\n");
|
||||
snprintf(path, sizeof(path), "%s/untracked.go", decoy);
|
||||
th_write_file(path, "package a\n");
|
||||
|
||||
git_env_snapshot_t snap;
|
||||
git_env_hook_enter(&snap, decoy);
|
||||
cbm_worktree_status_t st;
|
||||
int rc = cbm_git_worktree_status(fixture, 4, &st);
|
||||
git_env_hook_leave(&snap);
|
||||
bool available = st.available;
|
||||
int tracked = st.tracked_count;
|
||||
int untracked = st.untracked_count;
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(root);
|
||||
|
||||
ASSERT_EQ(rc, 0);
|
||||
ASSERT_TRUE(available);
|
||||
ASSERT_EQ(tracked, 0); /* the fixture is clean; the decoy is not */
|
||||
ASSERT_EQ(untracked, 0);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
/* ── Bounded worktree status (BT-240 increment 3) ──────────────────────
|
||||
* These run on EVERY platform, not just POSIX: they shell out through
|
||||
* cbm_popen instead of system(), so they work whenever git is on PATH —
|
||||
* Windows CI included. The
|
||||
* core status parser is platform-neutral and must be exercised everywhere git
|
||||
* is available; only a genuinely missing git skips. */
|
||||
|
||||
static int status_git_run(const char *dir, const char *args) {
|
||||
char cmd[1024];
|
||||
#ifdef _WIN32
|
||||
const char *null_dev = "NUL";
|
||||
#else
|
||||
const char *null_dev = "/dev/null";
|
||||
#endif
|
||||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev);
|
||||
FILE *fp = cbm_popen(cmd, "r");
|
||||
if (!fp) {
|
||||
return -1;
|
||||
}
|
||||
char drain[256];
|
||||
while (fgets(drain, sizeof(drain), fp)) {
|
||||
}
|
||||
return cbm_pclose(fp);
|
||||
}
|
||||
|
||||
static int status_make_repo(const char *dir) {
|
||||
if (th_mkdir_p(dir) != 0) return -1;
|
||||
if (status_git_run(dir, "init -q") != 0) return -1;
|
||||
if (status_git_run(dir, "config user.email test@example.com") != 0) return -1;
|
||||
if (status_git_run(dir, "config user.name Test") != 0) return -1;
|
||||
if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1;
|
||||
if (status_git_run(dir, "add -A") != 0) return -1;
|
||||
if (status_git_run(dir, "commit -qm init") != 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
TEST(worktree_status_clean) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
if (status_make_repo(tmp) != 0) {
|
||||
th_rmtree(tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, 8, &st);
|
||||
if (rc != 0 || !st.available) {
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
FAIL("clean repo status not available");
|
||||
}
|
||||
ASSERT_EQ(st.tracked_count, 0);
|
||||
ASSERT_EQ(st.untracked_count, 0);
|
||||
ASSERT_EQ(st.tracked_sample_count, 0);
|
||||
ASSERT_EQ(st.untracked_sample_count, 0);
|
||||
ASSERT_FALSE(st.tracked_truncated);
|
||||
ASSERT_FALSE(st.untracked_truncated);
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(worktree_status_tracked_modified) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
if (status_make_repo(tmp) != 0) {
|
||||
th_rmtree(tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 ||
|
||||
status_git_run(tmp, "add -f -- tracked.txt") != 0 ||
|
||||
status_git_run(tmp, "commit -q -m add-tracked") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to commit tracked file");
|
||||
}
|
||||
if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to modify tracked file");
|
||||
}
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, 8, &st);
|
||||
if (rc != 0 || !st.available) {
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
FAIL("status not available");
|
||||
}
|
||||
ASSERT_EQ(st.tracked_count, 1);
|
||||
ASSERT_EQ(st.untracked_count, 0);
|
||||
ASSERT_EQ(st.tracked_sample_count, 1);
|
||||
ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt");
|
||||
ASSERT_FALSE(st.tracked_truncated);
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(worktree_status_untracked) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
if (status_make_repo(tmp) != 0) {
|
||||
th_rmtree(tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(tmp, "newfile.txt"), "n") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to create untracked file");
|
||||
}
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, 8, &st);
|
||||
if (rc != 0 || !st.available) {
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
FAIL("status not available");
|
||||
}
|
||||
ASSERT_EQ(st.untracked_count, 1);
|
||||
ASSERT_EQ(st.tracked_count, 0);
|
||||
ASSERT_EQ(st.untracked_sample_count, 1);
|
||||
ASSERT_STR_EQ(st.untracked_paths[0], "newfile.txt");
|
||||
ASSERT_FALSE(st.untracked_truncated);
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(worktree_status_rename_counts_once) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
if (status_make_repo(tmp) != 0) {
|
||||
th_rmtree(tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(tmp, "old.txt"), "o") != 0 ||
|
||||
th_write_file(TH_PATH(tmp, "other.txt"), "x") != 0 ||
|
||||
status_git_run(tmp, "add -A") != 0 ||
|
||||
status_git_run(tmp, "commit -qm base") != 0 ||
|
||||
status_git_run(tmp, "mv old.txt new.txt") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to stage a rename");
|
||||
}
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, 8, &st);
|
||||
if (rc != 0 || !st.available) {
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
FAIL("status not available");
|
||||
}
|
||||
ASSERT_EQ(st.tracked_count, 1); /* rename/copy counts ONCE, not twice */
|
||||
ASSERT_EQ(st.tracked_sample_count, 1);
|
||||
ASSERT_STR_EQ(st.tracked_paths[0], "new.txt"); /* sampled NEW path */
|
||||
ASSERT_EQ(st.untracked_count, 0);
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(worktree_status_sample_cap_truncates) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
if (status_make_repo(tmp) != 0) {
|
||||
th_rmtree(tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
enum { N_FILES = 5, SAMPLE_MAX = 2 };
|
||||
for (int i = 0; i < N_FILES; i++) {
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/u%d.txt", tmp, i);
|
||||
if (th_write_file(path, "u") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to create untracked file");
|
||||
}
|
||||
}
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, SAMPLE_MAX, &st);
|
||||
if (rc != 0 || !st.available) {
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
FAIL("status not available");
|
||||
}
|
||||
ASSERT_EQ(st.untracked_count, N_FILES); /* full stream counted... */
|
||||
ASSERT_EQ(st.untracked_sample_count, SAMPLE_MAX); /* ...samples bounded */
|
||||
ASSERT_TRUE(st.untracked_truncated);
|
||||
ASSERT_EQ(st.tracked_count, 0);
|
||||
ASSERT_FALSE(st.tracked_truncated);
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(worktree_status_mixed_tracked_and_untracked) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
if (status_make_repo(tmp) != 0) {
|
||||
th_rmtree(tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 ||
|
||||
status_git_run(tmp, "add -f -- tracked.txt") != 0 ||
|
||||
status_git_run(tmp, "commit -q -m add-tracked") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to commit tracked file");
|
||||
}
|
||||
if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0 ||
|
||||
th_write_file(TH_PATH(tmp, "untracked.txt"), "n") != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("failed to prepare mixed worktree");
|
||||
}
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, 8, &st);
|
||||
if (rc != 0 || !st.available) {
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
FAIL("status not available");
|
||||
}
|
||||
ASSERT_EQ(st.tracked_count, 1);
|
||||
ASSERT_EQ(st.untracked_count, 1);
|
||||
ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt");
|
||||
ASSERT_STR_EQ(st.untracked_paths[0], "untracked.txt");
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Fail-closed contract: a non-repo directory must report unavailable with
|
||||
* zero counts — never a silent "clean" tree. Does not need git on PATH. */
|
||||
TEST(worktree_status_unavailable_non_repo) {
|
||||
char *tmp = th_mktempdir("cbm_wtstatus");
|
||||
if (!tmp) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
cbm_worktree_status_t st = {0};
|
||||
int rc = cbm_git_worktree_status(tmp, 8, &st);
|
||||
if (rc != 0) {
|
||||
th_rmtree(tmp);
|
||||
FAIL("status call returned an error");
|
||||
}
|
||||
ASSERT_FALSE(st.available);
|
||||
ASSERT_EQ(st.tracked_count, 0);
|
||||
ASSERT_EQ(st.untracked_count, 0);
|
||||
ASSERT_EQ(st.tracked_sample_count, 0);
|
||||
ASSERT_EQ(st.untracked_sample_count, 0);
|
||||
|
||||
cbm_git_worktree_status_free(&st);
|
||||
th_rmtree(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ──────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(git_context) {
|
||||
@@ -658,4 +966,12 @@ SUITE(git_context) {
|
||||
RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit);
|
||||
RUN_TEST(git_context_ignores_inherited_repo_env);
|
||||
RUN_TEST(githistory_ignores_inherited_repo_env);
|
||||
RUN_TEST(worktree_status_ignores_inherited_repo_env);
|
||||
RUN_TEST(worktree_status_clean);
|
||||
RUN_TEST(worktree_status_tracked_modified);
|
||||
RUN_TEST(worktree_status_untracked);
|
||||
RUN_TEST(worktree_status_rename_counts_once);
|
||||
RUN_TEST(worktree_status_sample_cap_truncates);
|
||||
RUN_TEST(worktree_status_mixed_tracked_and_untracked);
|
||||
RUN_TEST(worktree_status_unavailable_non_repo);
|
||||
}
|
||||
+469
-3
@@ -18,6 +18,7 @@
|
||||
#include "test_framework.h"
|
||||
#include "test_helpers.h"
|
||||
#include <cli/cli.h>
|
||||
#include <git/git_context.h>
|
||||
#include <mcp/index_supervisor.h> /* spawn-count hook — #845 in-process guard */
|
||||
#include <mcp/mcp.h>
|
||||
#include <mcp/mcp_internal.h>
|
||||
@@ -7890,9 +7891,465 @@ TEST(tool_index_status_includes_git_metadata) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* TOOL HANDLERS WITH DATA
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
/* BT-240 RED: a live checkout SHA is not proof of the generation that produced
|
||||
* graph content. Older databases lack an indexed-checkout identity, so verbose
|
||||
* status must fail closed as unknown while exposing graph generation
|
||||
* separately. */
|
||||
TEST(tool_index_status_fails_closed_without_indexed_checkout_identity) {
|
||||
char tmp[256];
|
||||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"test-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"freshness\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"indexed_generation\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":null"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_unavailable"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_snippet_dir(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* BT-240 lean-default guard: the freshness verdict is diagnostics — a
|
||||
* report-only signal that never auto-indexes — so it must stay out of the
|
||||
* common (non-verbose) status call. */
|
||||
TEST(tool_index_status_omits_freshness_by_default) {
|
||||
char tmp[256];
|
||||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status", "{\"project\":\"test-project\",\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"status\""));
|
||||
ASSERT_NULL(strstr(inner, "\"freshness\""));
|
||||
ASSERT_NULL(strstr(inner, "indexed_checkout_sha"));
|
||||
/* BT-240 increment 3: the worktree-status snapshot is part of the
|
||||
* verbose-only freshness block and must not leak into the default call. */
|
||||
ASSERT_NULL(strstr(inner, "status_available"));
|
||||
ASSERT_NULL(strstr(inner, "tracked_changes"));
|
||||
ASSERT_NULL(strstr(inner, "untracked_source"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_snippet_dir(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── BT-240: freshness verdict against the recorded indexed checkout ──
|
||||
* These shell out to git via cbm_popen (the same isolated spawn production
|
||||
* git_context uses) rather than system(), so they run on every platform that
|
||||
* has git on PATH — Windows CI included. A genuinely missing git is the only
|
||||
* skip. */
|
||||
|
||||
static int mcp_git_run(const char *dir, const char *args) {
|
||||
char cmd[1024];
|
||||
#ifdef _WIN32
|
||||
const char *null_dev = "NUL";
|
||||
#else
|
||||
const char *null_dev = "/dev/null";
|
||||
#endif
|
||||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev);
|
||||
FILE *fp = cbm_popen(cmd, "r");
|
||||
if (!fp) {
|
||||
return -1;
|
||||
}
|
||||
char drain[256];
|
||||
while (fgets(drain, sizeof(drain), fp)) {
|
||||
}
|
||||
return cbm_pclose(fp);
|
||||
}
|
||||
|
||||
static int mcp_make_git_repo(const char *dir) {
|
||||
if (th_mkdir_p(dir) != 0) return -1;
|
||||
if (mcp_git_run(dir, "init -q") != 0) return -1;
|
||||
if (mcp_git_run(dir, "config user.email test@example.com") != 0) return -1;
|
||||
if (mcp_git_run(dir, "config user.name Test") != 0) return -1;
|
||||
if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1;
|
||||
if (mcp_git_run(dir, "add -A") != 0) return -1;
|
||||
if (mcp_git_run(dir, "commit -qm init") != 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Helper: build a server whose project root is the given git repo and whose
|
||||
* coverage metadata records indexed_checkout_sha = sha (may be NULL). */
|
||||
static cbm_mcp_server_t *mcp_freshness_server(const char *repo, const char *sha) {
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
if (!srv) return NULL;
|
||||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||||
if (!st) {
|
||||
cbm_mcp_server_free(srv);
|
||||
return NULL;
|
||||
}
|
||||
cbm_mcp_server_set_project(srv, "fresh-project");
|
||||
cbm_store_upsert_project(st, "fresh-project", repo);
|
||||
cbm_coverage_meta_t meta = {
|
||||
.generation = "fresh-generation",
|
||||
.index_mode = "fast",
|
||||
.recorded_at = "2026-08-11T00:00:00Z",
|
||||
.recording_status = "complete",
|
||||
.coverage_version = 1,
|
||||
.hash_records_complete = true,
|
||||
.indexed_checkout_sha = sha,
|
||||
};
|
||||
if (cbm_store_coverage_replace_ex(st, "fresh-project", NULL, 0, &meta) != CBM_STORE_OK) {
|
||||
cbm_mcp_server_free(srv);
|
||||
return NULL;
|
||||
}
|
||||
return srv;
|
||||
}
|
||||
|
||||
TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) {
|
||||
char *repo = th_mktempdir("cbm_fresh_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
if (mcp_make_git_repo(repo) != 0) {
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
cbm_git_context_t ctx = {0};
|
||||
if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) {
|
||||
cbm_git_context_free(&ctx);
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git head not resolvable");
|
||||
}
|
||||
char *head = strdup(ctx.head_sha);
|
||||
cbm_git_context_free(&ctx);
|
||||
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, head);
|
||||
if (!srv) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"current\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_current"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"use_graph\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"status_available\":true"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"count\":0"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) {
|
||||
char *repo = th_mktempdir("cbm_fresh_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
if (mcp_make_git_repo(repo) != 0) {
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
|
||||
/* A stale recorded identity that does not equal the live HEAD. */
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0000000000000000000000000000000000000000");
|
||||
if (!srv) {
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_mismatch"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"0000000000000000000000000000000000000000\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"reindex_to_match_checkout\""));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* BT-240 increment 3: a modified tracked file makes the verdict stale even
|
||||
* when the indexed SHA matches the live HEAD, with the sample surfaced. */
|
||||
TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes) {
|
||||
char *repo = th_mktempdir("cbm_fresh_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
if (mcp_make_git_repo(repo) != 0) {
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 ||
|
||||
mcp_git_run(repo, "add -f -- tracked.txt") != 0 ||
|
||||
mcp_git_run(repo, "commit -q -m add-tracked") != 0) {
|
||||
th_rmtree(repo);
|
||||
FAIL("failed to commit tracked file");
|
||||
}
|
||||
|
||||
cbm_git_context_t ctx = {0};
|
||||
if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) {
|
||||
cbm_git_context_free(&ctx);
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git head not resolvable");
|
||||
}
|
||||
char *head = strdup(ctx.head_sha);
|
||||
cbm_git_context_free(&ctx);
|
||||
|
||||
if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("failed to modify tracked file");
|
||||
}
|
||||
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, head);
|
||||
if (!srv) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"count\":1"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"paths\":[\"tracked.txt\"]"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"truncated\":false"));
|
||||
ASSERT_NULL(strstr(inner, "indexed_checkout_current"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* BT-240 increment 3: untracked sources prevent "current" — verdict is
|
||||
* unknown (no stale reason), with the untracked_not_indexed reason. */
|
||||
TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only) {
|
||||
char *repo = th_mktempdir("cbm_fresh_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
if (mcp_make_git_repo(repo) != 0) {
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(repo, "newfile.txt"), "n") != 0) {
|
||||
th_rmtree(repo);
|
||||
FAIL("failed to create untracked file");
|
||||
}
|
||||
|
||||
cbm_git_context_t ctx = {0};
|
||||
if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) {
|
||||
cbm_git_context_free(&ctx);
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git head not resolvable");
|
||||
}
|
||||
char *head = strdup(ctx.head_sha);
|
||||
cbm_git_context_free(&ctx);
|
||||
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, head);
|
||||
if (!srv) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"count\":1"));
|
||||
ASSERT_NULL(strstr(inner, "indexed_checkout_current"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* BT-240 increment 3: tracked changes win over untracked — both reasons are
|
||||
* present but the verdict is stale, never unknown. */
|
||||
TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked) {
|
||||
char *repo = th_mktempdir("cbm_fresh_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
if (mcp_make_git_repo(repo) != 0) {
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 ||
|
||||
mcp_git_run(repo, "add -f -- tracked.txt") != 0 ||
|
||||
mcp_git_run(repo, "commit -q -m add-tracked") != 0) {
|
||||
th_rmtree(repo);
|
||||
FAIL("failed to commit tracked file");
|
||||
}
|
||||
|
||||
cbm_git_context_t ctx = {0};
|
||||
if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) {
|
||||
cbm_git_context_free(&ctx);
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git head not resolvable");
|
||||
}
|
||||
char *head = strdup(ctx.head_sha);
|
||||
cbm_git_context_free(&ctx);
|
||||
|
||||
if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0 ||
|
||||
th_write_file(TH_PATH(repo, "untracked.txt"), "n") != 0) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("failed to prepare mixed worktree");
|
||||
}
|
||||
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, head);
|
||||
if (!srv) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\""));
|
||||
ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* BT-240 increment 3: a non-git root with a recorded identity cannot prove
|
||||
* current — status availability must be false and the verdict unknown. */
|
||||
TEST(tool_index_status_freshness_status_unavailable_non_git) {
|
||||
char *repo = th_mktempdir("cbm_fresh_non_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
|
||||
/* Record an identity even though the root is not a git repository. */
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0123456789012345678901234567890123456789");
|
||||
if (!srv) {
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"status_available\":false"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "status_unavailable"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\""));
|
||||
ASSERT_NULL(strstr(inner, "indexed_checkout_current"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"count\":0"));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* BT-240 increment 3: sample cap at the MCP layer — many untracked sources
|
||||
* surface the true count, a bounded paths array and truncated=true. */
|
||||
TEST(tool_index_status_freshness_sample_cap_truncates) {
|
||||
char *repo = th_mktempdir("cbm_fresh_git");
|
||||
if (!repo) FAIL("th_mktempdir returned NULL");
|
||||
if (mcp_make_git_repo(repo) != 0) {
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
enum { N_FILES = 17, MCP_SAMPLE_MAX = 16 };
|
||||
for (int i = 0; i < N_FILES; i++) {
|
||||
char path[1024];
|
||||
snprintf(path, sizeof(path), "%s/u%02d.txt", repo, i);
|
||||
if (th_write_file(path, "u") != 0) {
|
||||
th_rmtree(repo);
|
||||
FAIL("failed to create untracked file");
|
||||
}
|
||||
}
|
||||
|
||||
cbm_git_context_t ctx = {0};
|
||||
if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) {
|
||||
cbm_git_context_free(&ctx);
|
||||
th_rmtree(repo);
|
||||
SKIP_PLATFORM("git head not resolvable");
|
||||
}
|
||||
char *head = strdup(ctx.head_sha);
|
||||
cbm_git_context_free(&ctx);
|
||||
|
||||
cbm_mcp_server_t *srv = mcp_freshness_server(repo, head);
|
||||
if (!srv) {
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
FAIL("mcp_freshness_server failed");
|
||||
}
|
||||
|
||||
char *resp = cbm_mcp_handle_tool(
|
||||
srv, "index_status",
|
||||
"{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
char *inner = extract_text_content(resp);
|
||||
ASSERT_NOT_NULL(inner);
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"count\":17"));
|
||||
ASSERT_NOT_NULL(strstr(inner, "\"truncated\":true"));
|
||||
static const char EXPECTED_PATHS[] =
|
||||
"\"paths\":[\"u00.txt\",\"u01.txt\",\"u02.txt\",\"u03.txt\",\"u04.txt\",\"u05.txt\","
|
||||
"\"u06.txt\",\"u07.txt\",\"u08.txt\",\"u09.txt\",\"u10.txt\",\"u11.txt\",\"u12.txt\","
|
||||
"\"u13.txt\",\"u14.txt\",\"u15.txt\"]";
|
||||
ASSERT_NOT_NULL(strstr(inner, EXPECTED_PATHS));
|
||||
|
||||
free(inner);
|
||||
free(resp);
|
||||
cbm_mcp_server_free(srv);
|
||||
free(head);
|
||||
th_rmtree(repo);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #2816: stored recursion must remain visible even when the CALLS graph has
|
||||
* no self-edges. Removing metadata emission must fail these assertions. */
|
||||
@@ -27236,6 +27693,15 @@ SUITE(mcp) {
|
||||
RUN_TEST(tool_responses_carry_store_generation_issue2585);
|
||||
RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors);
|
||||
RUN_TEST(tool_index_status_includes_git_metadata);
|
||||
RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity);
|
||||
RUN_TEST(tool_index_status_omits_freshness_by_default);
|
||||
RUN_TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches);
|
||||
RUN_TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches);
|
||||
RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes);
|
||||
RUN_TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only);
|
||||
RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked);
|
||||
RUN_TEST(tool_index_status_freshness_status_unavailable_non_git);
|
||||
RUN_TEST(tool_index_status_freshness_sample_cap_truncates);
|
||||
|
||||
/* Tool handlers with validation */
|
||||
RUN_TEST(tool_trace_path_recursion_metadata_issue2816);
|
||||
|
||||
@@ -683,6 +683,45 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) {
|
||||
cbm_store_close(a);cbm_store_close(b);PASS();
|
||||
}
|
||||
|
||||
/* BT-240 (#1561): the indexed checkout is source identity, which the digest
|
||||
* never binds, so it lives outside the digested tables. A store written through
|
||||
* the real coverage API, with and without a checkout SHA, keeps passing the
|
||||
* live-schema check (test-impact falls back to running everything otherwise),
|
||||
* and recording or changing the SHA leaves the digest unchanged. */
|
||||
TEST(store_graph_digest_indexed_checkout_identity_is_not_bound) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
ASSERT_EQ(cbm_store_upsert_project(s, "p", "/live"), CBM_STORE_OK);
|
||||
cbm_coverage_meta_t meta = {.generation = "g",
|
||||
.index_mode = "full",
|
||||
.recorded_at = "t",
|
||||
.recording_status = "complete",
|
||||
.coverage_version = 1,
|
||||
.hash_records_complete = true};
|
||||
cbm_store_graph_digest_t none = {0}, first = {0}, second = {0};
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK);
|
||||
bool none_ok = gd_p(s, &none);
|
||||
meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK);
|
||||
bool first_ok = gd_p(s, &first);
|
||||
meta.indexed_checkout_sha = "89abcdef0123456789abcdef0123456789abcdef";
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK);
|
||||
bool second_ok = gd_p(s, &second);
|
||||
cbm_coverage_meta_t got = {0};
|
||||
int got_rc = cbm_store_coverage_meta_get(s, "p", &got);
|
||||
bool sha_kept = got_rc == CBM_STORE_OK && got.indexed_checkout_sha &&
|
||||
strcmp(got.indexed_checkout_sha, "89abcdef0123456789abcdef0123456789abcdef") == 0;
|
||||
cbm_store_coverage_meta_clear(&got);
|
||||
cbm_store_close(s);
|
||||
ASSERT_TRUE(none_ok);
|
||||
ASSERT_TRUE(first_ok);
|
||||
ASSERT_TRUE(second_ok);
|
||||
ASSERT_TRUE(sha_kept);
|
||||
ASSERT_TRUE(gd_equal(&none, &first));
|
||||
ASSERT_TRUE(gd_equal(&first, &second));
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(store_graph_digest) {
|
||||
RUN_TEST(store_graph_digest_binds_unresolved_candidate_index);
|
||||
RUN_TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence);
|
||||
@@ -699,4 +738,5 @@ SUITE(store_graph_digest) {
|
||||
RUN_TEST(store_graph_digest_cancellation_large_blob_never_publishes_prefix);
|
||||
RUN_TEST(store_graph_digest_sql_errors_cleanup_no_mutation_and_close_discard);
|
||||
RUN_TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates);
|
||||
RUN_TEST(store_graph_digest_indexed_checkout_identity_is_not_bound);
|
||||
}
|
||||
@@ -2706,6 +2706,75 @@ TEST(store_coverage_shadow_excludes_unresolved_calls) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-sha", "/tmp/coverage-sha"), CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-sha", "keep.c", "", 1, 1), CBM_STORE_OK);
|
||||
cbm_coverage_row_t row = {.rel_path = "keep.c", .kind = "parse_partial", .detail = "1-2"};
|
||||
|
||||
/* NULL indexed_checkout_sha (non-git run) persists as NULL and reads back
|
||||
* as no identity. */
|
||||
cbm_coverage_meta_t no_sha_meta = {
|
||||
.generation = "gen-0",
|
||||
.index_mode = "full",
|
||||
.recording_status = "complete",
|
||||
.coverage_version = 1,
|
||||
.hash_records_complete = true,
|
||||
};
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &no_sha_meta),
|
||||
CBM_STORE_OK);
|
||||
cbm_coverage_meta_t got = {0};
|
||||
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK);
|
||||
ASSERT_NULL(got.indexed_checkout_sha);
|
||||
cbm_store_coverage_meta_clear(&got);
|
||||
|
||||
/* Round-trip: git-run identity persists and can be overwritten by the next
|
||||
* staged generation. */
|
||||
cbm_coverage_meta_t sha_meta = no_sha_meta;
|
||||
sha_meta.generation = "gen-1";
|
||||
sha_meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &sha_meta),
|
||||
CBM_STORE_OK);
|
||||
got = (cbm_coverage_meta_t){0};
|
||||
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK);
|
||||
ASSERT_STR_EQ(got.indexed_checkout_sha, "0123456789abcdef0123456789abcdef01234567");
|
||||
ASSERT_STR_EQ(got.generation, "gen-1");
|
||||
cbm_store_coverage_meta_clear(&got);
|
||||
|
||||
cbm_coverage_meta_t new_meta = sha_meta;
|
||||
new_meta.generation = "gen-2";
|
||||
new_meta.indexed_checkout_sha = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &new_meta),
|
||||
CBM_STORE_OK);
|
||||
got = (cbm_coverage_meta_t){0};
|
||||
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK);
|
||||
ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01");
|
||||
ASSERT_STR_EQ(got.generation, "gen-2");
|
||||
cbm_store_coverage_meta_clear(&got);
|
||||
|
||||
/* Rollback: a failed replace must retain the prior identity + generation. */
|
||||
ASSERT_EQ(cbm_store_exec(s, "CREATE TRIGGER fail_sha_missed_insert BEFORE INSERT ON nodes "
|
||||
"WHEN NEW.project = 'coverage-sha::missed' "
|
||||
"BEGIN SELECT RAISE(ABORT, 'forced sha shadow failure'); END;"),
|
||||
CBM_STORE_OK);
|
||||
cbm_coverage_meta_t failed_meta = new_meta;
|
||||
failed_meta.generation = "gen-must-not-commit";
|
||||
failed_meta.indexed_checkout_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
cbm_coverage_row_t failed_row = row;
|
||||
failed_row.detail = "changed-to-force-shadow-rebuild";
|
||||
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &failed_row, 1, &failed_meta),
|
||||
CBM_STORE_ERR);
|
||||
got = (cbm_coverage_meta_t){0};
|
||||
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK);
|
||||
ASSERT_STR_EQ(got.generation, "gen-2");
|
||||
ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01");
|
||||
cbm_store_coverage_meta_clear(&got);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(store_nodes) {
|
||||
RUN_TEST(store_qn_suffix_is_literal);
|
||||
RUN_TEST(store_qn_suffix_is_unbounded);
|
||||
@@ -2716,6 +2785,7 @@ SUITE(store_nodes) {
|
||||
RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments);
|
||||
RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails);
|
||||
RUN_TEST(store_coverage_shadow_excludes_unresolved_calls);
|
||||
RUN_TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback);
|
||||
RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like);
|
||||
RUN_TEST(sql_relation_labels_match_cbm_label_is_relation);
|
||||
RUN_TEST(store_open_memory);
|
||||
|
||||
Reference in new issue
Block a user