fix(mcp): distinguish dirty worktree from HEAD

Previous behavior:
- index_status.git returned head_sha and base_sha without stating that HEAD identifies committed content only or whether the current filesystem differed from HEAD.
- A consumer could treat the SHA as the identity of a dirty working tree.

Changes:
- Reuse cbm_git_snapshot_read(CBM_GIT_SNAPSHOT_DIRTY) in add_git_context_json() at src/mcp/mcp.c:3106-3126.
- Emit head_scope=committed_revision_only, worktree_dirty, head_matches_worktree, worktree_state, and a dirty_hash only for dirty repositories.
- Emit null dirty/match fields plus worktree_state=unknown when snapshot capture is unavailable instead of claiming the tree is clean.
- State the HEAD/working-tree distinction in the shared index_status tool description.

Scope and lifecycle:
- Dirty status is computed only for single-project index_status responses; list_projects and search paths gain no git-status scan.
- cbm_git_snapshot_t is stack-owned and add_git_context_json() retains its existing cbm_git_context_free() cleanup.

Verification:
- Red: focused test failed at tests/test_mcp.c:3115 because worktree_dirty was absent.
- Green: clean-to-dirty focused test passed 1/1; MCP ASan/UBSan passed 223/223; tool_consolidation passed 100/100.
- make -f Makefile.cbm lint-source-safety passed both checks.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-16 04:25:36 -04:00
parent c406d8c5d4
commit 76f7796fb8
2 changed files with 98 additions and 1 deletions
+24 -1
View File
@@ -47,6 +47,7 @@ enum {
#include "pagerank/pagerank.h"
#include "pipeline/pass_cross_repo.h"
#include "git/git_context.h"
#include "git/git_snapshot.h"
#include "cli/cli.h"
#include "watcher/watcher.h"
#include "foundation/mem.h"
@@ -1258,7 +1259,8 @@ static const tool_def_t TOOLS[] = {
{"index_status", "Index status",
"Report project index freshness, graph counts, overlay read-view counts, and background "
"overlay compaction state.",
"overlay compaction state. Git metadata labels HEAD as committed-only and reports whether "
"the current working tree is dirty.",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\",\"description\":"
"\"Indexed project name to inspect.\"}},\"required\":["
"\"project\"]}"},
@@ -3101,6 +3103,27 @@ static void add_git_context_json(yyjson_mut_doc *doc, yyjson_mut_val *obj, const
add_git_context_string(doc, git, "branch_slug", ctx.branch_slug);
add_git_context_string(doc, git, "head_sha", ctx.head_sha);
add_git_context_string(doc, git, "base_sha", ctx.base_sha);
if (ctx.is_git) {
yyjson_mut_obj_add_str(doc, git, "head_scope", "committed_revision_only");
cbm_git_snapshot_t snapshot = {0};
bool snapshot_available =
cbm_git_snapshot_read(root_path, CBM_GIT_SNAPSHOT_DIRTY, &snapshot) == 0 &&
snapshot.is_git;
if (snapshot_available) {
bool worktree_dirty = snapshot.dirty_bytes > 0;
yyjson_mut_obj_add_bool(doc, git, "worktree_dirty", worktree_dirty);
yyjson_mut_obj_add_bool(doc, git, "head_matches_worktree", !worktree_dirty);
yyjson_mut_obj_add_str(doc, git, "worktree_state",
worktree_dirty ? "dirty" : "clean");
if (worktree_dirty) {
yyjson_mut_obj_add_strcpy(doc, git, "dirty_hash", snapshot.dirty_hash);
}
} else {
yyjson_mut_obj_add_null(doc, git, "worktree_dirty");
yyjson_mut_obj_add_null(doc, git, "head_matches_worktree");
yyjson_mut_obj_add_str(doc, git, "worktree_state", "unknown");
}
}
yyjson_mut_obj_add_val(doc, obj, "git", git);
cbm_git_context_free(&ctx);
+74
View File
@@ -3065,6 +3065,79 @@ TEST(tool_index_status_includes_git_metadata) {
PASS();
}
#ifndef _WIN32
static int mcp_test_git_run(const char *dir, const char *args) {
char cmd[1024];
snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s >/dev/null 2>&1", dir, args);
return system(cmd);
}
#endif
TEST(tool_index_status_distinguishes_dirty_worktree_from_head) {
#ifdef _WIN32
SKIP_PLATFORM("git dirty-worktree status test is not supported on Windows CI");
#else
char *tmp = th_mktempdir("cbm-status-git");
ASSERT_NOT_NULL(tmp);
if (mcp_test_git_run(tmp, "init -q") != 0 ||
mcp_test_git_run(tmp, "config user.email test@example.com") != 0 ||
mcp_test_git_run(tmp, "config user.name Test") != 0) {
th_rmtree(tmp);
SKIP_PLATFORM("git is unavailable");
}
char source_path[CBM_SZ_1K];
snprintf(source_path, sizeof(source_path), "%s/main.c", tmp);
ASSERT_EQ(th_write_file(source_path, "int main(void) { return 0; }\n"), 0);
ASSERT_EQ(mcp_test_git_run(tmp, "add main.c"), 0);
ASSERT_EQ(mcp_test_git_run(tmp, "commit -q -m initial"), 0);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
const char *project = "status-git-identity";
ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK);
cbm_mcp_server_set_project(srv, project);
cbm_node_t node = {.project = project,
.label = "Function",
.name = "main",
.qualified_name = "status-git-identity.main",
.file_path = "main.c"};
ASSERT_GT(cbm_store_upsert_node(store, &node), 0);
char *response = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":161,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"status-git-identity\"}}}");
ASSERT_NOT_NULL(response);
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"worktree_dirty\":false"));
ASSERT_NOT_NULL(strstr(inner, "\"head_matches_worktree\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"head_scope\":\"committed_revision_only\""));
free(inner);
free(response);
ASSERT_EQ(th_write_file(source_path, "int main(void) { return 1; }\n"), 0);
response = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":162,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"status-git-identity\"}}}");
ASSERT_NOT_NULL(response);
inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"worktree_dirty\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"head_matches_worktree\":false"));
ASSERT_NOT_NULL(strstr(inner, "\"head_scope\":\"committed_revision_only\""));
free(inner);
free(response);
cbm_mcp_server_free(srv);
th_rmtree(tmp);
PASS();
#endif
}
TEST(tool_index_status_reports_dirty_metadata) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
@@ -9821,6 +9894,7 @@ SUITE(mcp) {
RUN_TEST(tool_query_graph_warns_on_stale_similarity_edges);
RUN_TEST(tool_index_status_no_project);
RUN_TEST(tool_index_status_includes_git_metadata);
RUN_TEST(tool_index_status_distinguishes_dirty_worktree_from_head);
RUN_TEST(tool_index_status_reports_dirty_metadata);
RUN_TEST(tool_index_status_reports_overlay_read_view_counts);