Merge pull request #2334 from DeusData/fix/issue-1951

fix(mcp): translate Git-root paths for subdirectory projects in detect_changes (#1951)
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-09-30 22:19:07 +02:00
commit e2f9fcc399
2 files changed
+220 -38

No files matched your search

+79 -38
View File
@@ -15597,6 +15597,48 @@ static bool detect_add_changed_path(char ***files, int *file_count, int *file_ca
return true;
}
/* Git status porcelain always reports paths relative to the Git ROOT, while
* graph file_paths are relative to the indexed project root, which may be a
* subdirectory of the repository (#1951). `prefix` is that subdirectory as
* `git rev-parse --show-prefix` prints it ("" at the root, else "dir/").
* Returns the project-relative tail, or NULL for a path outside the project. */
static const char *detect_project_relative_path(const char *git_path, const char *prefix) {
size_t prefix_length = strlen(prefix);
if (strncmp(git_path, prefix, prefix_length) != 0) {
return NULL;
}
return git_path + prefix_length;
}
/* A `--show-prefix` answer: empty at the repository root, otherwise a
* '/'-terminated relative directory. */
static bool detect_valid_git_prefix(const char *value) {
size_t length = strlen(value);
return length == 0 || value[length - 1] == '/';
}
/* Read one '\n'-terminated `git rev-parse` record (a trailing '\r' dropped)
* into out when it is terminated, fits, and passes `valid`. Anything else is
* a malformed answer and fails the request closed. */
static bool detect_read_rev_parse_line(FILE *stream, bool *oom, char *out, size_t out_size,
bool (*valid)(const char *)) {
bool terminated = false;
char *record = detect_read_record(stream, '\n', oom, &terminated);
if (!record) {
return false;
}
size_t length = strlen(record);
if (length > 0 && record[length - 1] == '\r') {
record[--length] = '\0';
}
bool ok = terminated && length < out_size && valid(record);
if (ok) {
memcpy(out, record, length + 1U);
}
free(record);
return ok;
}
static int detect_changed_path_compare(const void *left, const void *right) {
const char *const *left_path = left;
const char *const *right_path = right;
@@ -15947,15 +15989,20 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
* HEAD advance cannot mix revisions within one answer. */
char head_oid[65] = "";
char base_oid[65] = "";
/* The project root may be a subdirectory of the Git worktree (#1951);
* --show-prefix names it so every changed path can be translated from the
* Git-root coordinate system into the graph's project-relative one. */
char git_prefix[CBM_SZ_4K] = "";
bool git_prefix_valid = false;
char resolve_cmd[CBM_SZ_2K];
#ifdef _WIN32
snprintf(resolve_cmd, sizeof(resolve_cmd),
"git -C \"%s\" rev-parse \"HEAD^{commit}\" \"%s^{commit}\" 2>NUL", root_path,
base_branch);
"git -C \"%s\" rev-parse \"HEAD^{commit}\" \"%s^{commit}\" --show-prefix 2>NUL",
root_path, base_branch);
#else
snprintf(resolve_cmd, sizeof(resolve_cmd),
"git -C '%s' rev-parse 'HEAD^{commit}' '%s^{commit}' 2>/dev/null", root_path,
base_branch);
"git -C '%s' rev-parse 'HEAD^{commit}' '%s^{commit}' --show-prefix 2>/dev/null",
root_path, base_branch);
#endif
char resolve_output_path[CBM_SZ_2K] = {0};
cbm_proc_result_t resolve_result = {0};
@@ -15963,43 +16010,24 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
mcp_run_shell_command_cancellable(srv, resolve_cmd, resolve_output_path, &resolve_result);
bool resolve_cancelled = resolve_result.cancellation_requested || mcp_request_cancelled(srv);
bool resolve_oom = false;
char *resolved_head = NULL;
char *resolved_base = NULL;
FILE *resolve_fp = resolve_run == 0 && resolve_result.exit_code == 0 && !resolve_cancelled
? cbm_fopen(resolve_output_path, "rb")
: NULL;
if (resolve_fp) {
bool head_terminated = false;
bool base_terminated = false;
resolved_head = detect_read_record(resolve_fp, '\n', &resolve_oom, &head_terminated);
resolved_base = detect_read_record(resolve_fp, '\n', &resolve_oom, &base_terminated);
if (resolved_head) {
size_t length = strlen(resolved_head);
if (length > 0 && resolved_head[length - 1] == '\r') {
resolved_head[--length] = '\0';
}
if (head_terminated && detect_valid_object_id(resolved_head)) {
memcpy(head_oid, resolved_head, length + 1U);
}
}
if (resolved_base) {
size_t length = strlen(resolved_base);
if (length > 0 && resolved_base[length - 1] == '\r') {
resolved_base[--length] = '\0';
}
if (base_terminated && detect_valid_object_id(resolved_base)) {
memcpy(base_oid, resolved_base, length + 1U);
}
}
/* Records in argument order: HEAD, base, then the --show-prefix line. */
(void)detect_read_rev_parse_line(resolve_fp, &resolve_oom, head_oid, sizeof(head_oid),
detect_valid_object_id);
(void)detect_read_rev_parse_line(resolve_fp, &resolve_oom, base_oid, sizeof(base_oid),
detect_valid_object_id);
git_prefix_valid = detect_read_rev_parse_line(resolve_fp, &resolve_oom, git_prefix,
sizeof(git_prefix), detect_valid_git_prefix);
(void)fclose(resolve_fp);
}
free(resolved_head);
free(resolved_base);
if (resolve_output_path[0]) {
(void)cbm_unlink(resolve_output_path);
}
if (resolve_cancelled || resolve_run != 0 || resolve_result.exit_code != 0 || resolve_oom ||
!head_oid[0] || !base_oid[0]) {
!head_oid[0] || !base_oid[0] || !git_prefix_valid) {
free(direction);
free(root_path);
free(project);
@@ -16012,6 +16040,12 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
return cbm_mcp_text_result(
"git revision resolution failed: the contained command could not complete", true);
}
if (head_oid[0] && base_oid[0] && !resolve_oom) {
return cbm_mcp_text_result(
"git revision resolution failed: the project root's path inside the Git "
"worktree could not be determined",
true);
}
return cbm_mcp_text_result(
"git revision resolution failed: base_branch or HEAD is not a commit", true);
}
@@ -16111,7 +16145,9 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
free(record);
break;
}
if (!detect_add_changed_path(&files, &file_count, &file_cap, record)) {
const char *project_path = detect_project_relative_path(record, git_prefix);
if (project_path &&
!detect_add_changed_path(&files, &file_count, &file_cap, project_path)) {
changed_path_oom = true;
free(record);
break;
@@ -16186,7 +16222,10 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
free(record);
break;
}
if (!detect_add_changed_path(&files, &file_count, &file_cap, record + PAIR_LEN + 1U)) {
const char *project_path =
detect_project_relative_path(record + PAIR_LEN + 1U, git_prefix);
if (project_path &&
!detect_add_changed_path(&files, &file_count, &file_cap, project_path)) {
changed_path_oom = true;
free(record);
break;
@@ -16303,20 +16342,22 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
* index combined with insertions earlier in the file shifts the node lines
* relative to the hunks and can mis-scope. The failure is bounded by
* detect_collect_seeds' zero-overlap fallback: a file whose definitions all
* miss reverts to whole-file seeding rather than dropping out. */
* miss reverts to whole-file seeding rather than dropping out.
* --relative keeps hunk paths in the same project-relative coordinates as
* `files` when the project root is a repository subdirectory (#1951). */
cbm_changed_hunk_t *hunks = NULL;
int hunk_count = 0;
if (want_symbols) {
char hunk_cmd[CBM_SZ_2K];
#ifdef _WIN32
snprintf(hunk_cmd, sizeof(hunk_cmd),
"git -C \"%s\" diff --unified=0 \"%s\" \"%s\" -- 2>NUL && "
"git -C \"%s\" diff --unified=0 -- 2>NUL",
"git -C \"%s\" diff --relative --unified=0 \"%s\" \"%s\" -- 2>NUL && "
"git -C \"%s\" diff --relative --unified=0 -- 2>NUL",
root_path, merge_base, head_oid, root_path);
#else
snprintf(hunk_cmd, sizeof(hunk_cmd),
"git -C '%s' diff --unified=0 '%s' '%s' -- 2>/dev/null && "
"git -C '%s' diff --unified=0 -- 2>/dev/null",
"git -C '%s' diff --relative --unified=0 '%s' '%s' -- 2>/dev/null && "
"git -C '%s' diff --relative --unified=0 -- 2>/dev/null",
root_path, merge_base, head_oid, root_path);
#endif
char hunk_output_path[CBM_SZ_2K] = {0};
+141
View File
@@ -14269,6 +14269,146 @@ TEST(tool_detect_changes_finds_nested_untracked_file_and_impact_seed) {
PASS();
}
/* Issue #1951: the indexed project root is a SUBDIRECTORY of a normal Git
* repository. Git reports changed paths relative to the Git root
* ("game/src/math.c"), while graph file_paths are relative to the project
* root ("src/math.c"). detect_changes must translate Git-root paths into the
* project's coordinate system (diff, hunk and status records alike) and leave
* changes outside the project out, or the seed lookup finds nothing. */
TEST(tool_detect_changes_subdirectory_project_translates_git_root_paths_issue1951) {
char repo[CBM_SZ_4K];
snprintf(repo, sizeof(repo), "%s/cbm-detect-subdir-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
char cache[CBM_SZ_4K];
snprintf(cache, sizeof(cache), "%s/cbm-detect-subdir-cache-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
char project_root[CBM_SZ_4K];
snprintf(project_root, sizeof(project_root), "%s/game", repo);
ASSERT_EQ(cbm_mkdir(project_root), 0);
char source_dir[CBM_SZ_4K];
snprintf(source_dir, sizeof(source_dir), "%s/src", project_root);
ASSERT_EQ(cbm_mkdir(source_dir), 0);
char math_source[CBM_SZ_4K];
snprintf(math_source, sizeof(math_source), "%s/math.c", source_dir);
ASSERT_EQ(th_write_file(math_source, "int add_one(int x) { return x + 1; }\n"
"int untouched(void) { return 0; }\n"),
0);
char use_source[CBM_SZ_4K];
snprintf(use_source, sizeof(use_source), "%s/use.c", source_dir);
ASSERT_EQ(
th_write_file(use_source, "int add_one(int x);\nint use_it(void) { return add_one(4); }\n"),
0);
char outside_source[CBM_SZ_4K];
snprintf(outside_source, sizeof(outside_source), "%s/outside.c", repo);
ASSERT_EQ(th_write_file(outside_source, "int outside(void) { return 0; }\n"), 0);
const char *const init_args[] = {"init", "-q", NULL};
const char *const add_args[] = {"add", "-A", NULL};
const char *const commit_args[] = {
"-c", "user.name=cbm-test",
"-c", "user.email=cbm-test@example.invalid",
"-c", "commit.gpgsign=false",
"commit", "-q",
"-m", "fixture",
NULL,
};
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
ASSERT_EQ(mcp_test_git(repo, add_args), 0);
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
/* Worktree edits: a tracked change inside the project (diff + hunk path),
* an untracked file inside it (status path) and a change outside it. */
ASSERT_EQ(th_write_file(math_source, "int add_one(int x) { return x + 2; }\n"
"int untouched(void) { return 0; }\n"),
0);
char fresh_source[CBM_SZ_4K];
snprintf(fresh_source, sizeof(fresh_source), "%s/fresh.c", source_dir);
ASSERT_EQ(th_write_file(fresh_source, "int fresh(void) { return 1; }\n"), 0);
ASSERT_EQ(th_write_file(outside_source, "int outside(void) { return 1; }\n"), 0);
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 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 = "detect-subdir-project";
ASSERT_EQ(cbm_store_upsert_project(store, project, project_root), CBM_STORE_OK);
cbm_mcp_server_set_project(srv, project);
cbm_node_t seed = {.project = project,
.label = "Function",
.name = "add_one",
.qualified_name = "fixture.src.math.add_one",
.file_path = "src/math.c",
.start_line = 1,
.end_line = 1};
int64_t seed_id = cbm_store_upsert_node(store, &seed);
ASSERT_GT(seed_id, 0);
/* Same file, untouched line: only hunk scoping (project-relative hunk
* paths) keeps it out of the seeds; whole-file fallback would add it. */
cbm_node_t untouched = {.project = project,
.label = "Function",
.name = "untouched",
.qualified_name = "fixture.src.math.untouched",
.file_path = "src/math.c",
.start_line = 2,
.end_line = 2};
ASSERT_GT(cbm_store_upsert_node(store, &untouched), 0);
cbm_node_t caller = {.project = project,
.label = "Function",
.name = "use_it",
.qualified_name = "fixture.src.use.use_it",
.file_path = "src/use.c",
.start_line = 2,
.end_line = 2};
int64_t caller_id = cbm_store_upsert_node(store, &caller);
ASSERT_GT(caller_id, 0);
cbm_edge_t edge = {
.project = project, .source_id = caller_id, .target_id = seed_id, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(store, &edge), 0);
char *response =
cbm_mcp_handle_tool(srv, "detect_changes",
"{\"project\":\"detect-subdir-project\",\"base_branch\":\"HEAD\","
"\"scope\":\"impact\",\"depth\":2,\"max_output_tokens\":10000,"
"\"format\":\"json\"}");
char *inner = extract_text_content(response);
yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
yyjson_val *changed_files = root ? yyjson_obj_get(root, "changed_files") : NULL;
yyjson_val *first_path = changed_files ? yyjson_arr_get(changed_files, 0) : NULL;
yyjson_val *second_path = changed_files ? yyjson_arr_get(changed_files, 1) : NULL;
yyjson_val *impacted = root ? yyjson_obj_get(root, "impacted") : NULL;
yyjson_val *first_impact = impacted ? yyjson_arr_get(impacted, 0) : NULL;
bool project_relative_paths =
root && yyjson_get_int(yyjson_obj_get(root, "changed_total")) == 2 && first_path &&
second_path && strcmp(yyjson_get_str(first_path), "src/fresh.c") == 0 &&
strcmp(yyjson_get_str(second_path), "src/math.c") == 0;
bool seed_found = root && yyjson_get_int(yyjson_obj_get(root, "seed_symbols")) == 1;
bool impact_found = first_impact && strcmp(yyjson_get_str(yyjson_obj_get(first_impact, "qn")),
"fixture.src.use.use_it") == 0;
if (!project_relative_paths || !seed_found || !impact_found) {
fprintf(stderr, " issue1951 response: %s\n", inner ? inner : "(null)");
}
yyjson_doc_free(doc);
free(inner);
free(response);
cbm_mcp_server_free(srv);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
ASSERT_EQ(th_rmtree(cache), 0);
ASSERT_EQ(th_rmtree(repo), 0);
ASSERT_TRUE(project_relative_paths);
ASSERT_TRUE(seed_found);
ASSERT_TRUE(impact_found);
PASS();
}
TEST(tool_detect_changes_escapes_newline_path_in_tree_and_round_trips_json) {
#ifdef _WIN32
/* Win32 rejects control characters in filenames, so Windows cannot create
@@ -20841,6 +20981,7 @@ SUITE(mcp) {
RUN_TEST(tool_detect_changes_invalid_base_is_an_error);
RUN_TEST(tool_detect_changes_preserves_utf8_git_path_and_impact_seed);
RUN_TEST(tool_detect_changes_finds_nested_untracked_file_and_impact_seed);
RUN_TEST(tool_detect_changes_subdirectory_project_translates_git_root_paths_issue1951);
RUN_TEST(tool_detect_changes_escapes_newline_path_in_tree_and_round_trips_json);
RUN_TEST(tool_detect_changes_staged_rename_uses_exact_destination_record);
RUN_TEST(tool_detect_changes_contained_commands_clean_up_error_and_success);