diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index f21cd3140..5f9d89d9d 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -841,6 +841,18 @@ int cbm_exec_no_shell(const char *const *argv) { #endif /* _WIN32 */ +int cbm_pclose_exit_code(FILE *f) { + int status = cbm_pclose(f); +#ifdef _WIN32 + return status; +#else + if (status >= 0 && WIFEXITED(status)) { + return WEXITSTATUS(status); + } + return CBM_NOT_FOUND; +#endif +} + static void set_file_error(cbm_atomic_file_error_t *out, const char *stage, int code) { if (out) { out->stage = stage; diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index 0ef21dfd6..3d07f21d9 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -43,6 +43,8 @@ void cbm_closedir(cbm_dir_t *d); FILE *cbm_popen(const char *cmd, const char *mode); int cbm_pclose(FILE *f); +/* Close/reap a popen stream and return a platform-independent child exit code. */ +int cbm_pclose_exit_code(FILE *f); /* ── File operations ──────────────────────────────────────────── */ diff --git a/src/git/git_command.c b/src/git/git_command.c index 1020680bc..04fbd0e1b 100644 --- a/src/git/git_command.c +++ b/src/git/git_command.c @@ -110,6 +110,47 @@ int cbm_git_capture_first_line(const char *repo_path, const char *git_args, char return *out ? 0 : CBM_NOT_FOUND; } +int cbm_git_run_first_line_buf(const char *repo_path, const char *git_args, + char *out, size_t out_size, int *out_exit_code) { + if (!out || out_size == 0 || !out_exit_code) { + return CBM_NOT_FOUND; + } + out[0] = '\0'; + *out_exit_code = CBM_NOT_FOUND; + char cmd[CBM_GIT_CMD_BUFSZ]; + if (!cbm_git_format_command(cmd, sizeof(cmd), repo_path, git_args)) { + return CBM_NOT_FOUND; + } + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return CBM_NOT_FOUND; + } + + char line[CBM_GIT_OUTPUT_BUFSZ]; + bool got_line = fgets(line, (int)sizeof(line), fp) != NULL; + size_t line_len = got_line ? strlen(line) : 0; + bool truncated = got_line && line_len > 0 && line[line_len - 1] != '\n' && !feof(fp); + bool output_fits = true; + if (got_line) { + git_trim_newlines(line); + size_t value_len = strlen(line); + if (value_len >= out_size) { + output_fits = false; + } else { + memcpy(out, line, value_len + 1); + } + } + char drain[CBM_SZ_128]; + while (fgets(drain, (int)sizeof(drain), fp)) { + } + *out_exit_code = cbm_pclose_exit_code(fp); + if (truncated || !output_fits) { + out[0] = '\0'; + return CBM_NOT_FOUND; + } + return 0; +} + int cbm_git_drain_command(const char *repo_path, const char *git_args) { char cmd[CBM_GIT_CMD_BUFSZ]; if (!cbm_git_format_command(cmd, sizeof(cmd), repo_path, git_args)) { diff --git a/src/git/git_command.h b/src/git/git_command.h index 288f81af3..fc3e5ab89 100644 --- a/src/git/git_command.h +++ b/src/git/git_command.h @@ -20,6 +20,8 @@ bool cbm_git_format_status_command(char *cmd, size_t cmd_size, const char *repo_ int cbm_git_capture_first_line_buf(const char *repo_path, const char *git_args, char *out, size_t out_size); int cbm_git_capture_first_line(const char *repo_path, const char *git_args, char **out); +int cbm_git_run_first_line_buf(const char *repo_path, const char *git_args, + char *out, size_t out_size, int *out_exit_code); int cbm_git_drain_command(const char *repo_path, const char *git_args); #endif diff --git a/src/git/git_context.c b/src/git/git_context.c index 878da0a8e..3abb18404 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -112,6 +112,28 @@ static char *derive_canonical_root(const char *input_path, const char *worktree_ return root; } +static int resolve_current_branch(const char *path, char **out_branch) { + if (!out_branch) { + return CBM_NOT_FOUND; + } + *out_branch = NULL; + if (!path || !path[0]) { + return CBM_NOT_FOUND; + } + char branch[CBM_GIT_OUTPUT_BUFSZ]; + int exit_code = CBM_NOT_FOUND; + if (cbm_git_run_first_line_buf(path, "symbolic-ref --quiet --short HEAD", branch, + sizeof(branch), &exit_code) != 0) { + return CBM_NOT_FOUND; + } + const char *resolved = exit_code == 0 && branch[0] ? branch : exit_code == 1 ? "DETACHED" : NULL; + if (!resolved) { + return CBM_NOT_FOUND; + } + *out_branch = cbm_strdup(resolved); + return *out_branch ? 0 : CBM_NOT_FOUND; +} + static char *slug_from_branch(const char *branch, bool detached) { const char *fallback = detached ? "detached" : "working-tree"; const char *src = detached ? fallback : (branch && branch[0] ? branch : fallback); @@ -203,9 +225,10 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { out->head_sha = cbm_strdup(""); } - if (cbm_git_capture_first_line(path, "symbolic-ref --quiet --short HEAD", &out->branch) != - 0) { - out->branch = cbm_strdup("DETACHED"); + if (resolve_current_branch(path, &out->branch) != 0) { + out->branch = NULL; + } + if (out->branch && strcmp(out->branch, "DETACHED") == 0) { out->is_detached = true; } @@ -233,6 +256,10 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { return 0; } +int cbm_git_current_branch(const char *path, char **out_branch) { + return resolve_current_branch(path, out_branch); +} + char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx) { const char *project = project_name && project_name[0] ? project_name : "project"; const char *slug = "working-tree"; diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..e914543f0 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -21,6 +21,9 @@ typedef struct { int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); +/* Resolve only the current branch for branch-only callers. Returns a heap + * string (including "DETACHED") through out_branch on success. */ +int cbm_git_current_branch(const char *path, char **out_branch); 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); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 25ea1575f..218b7b4c5 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -3891,12 +3891,11 @@ static bool build_project_json_entry(yyjson_mut_doc *doc, yyjson_mut_val *arr, c * null for non-git roots — cost ~10KB across a full cache and is one * index_status call away for the project you actually care about. */ if (root_path_buf[0]) { - cbm_git_context_t gctx = {0}; - (void)cbm_git_context_resolve(root_path_buf, &gctx); - if (gctx.is_git && gctx.branch) { - yyjson_mut_obj_add_strcpy(doc, p, "branch", gctx.branch); + char *branch = NULL; + if (cbm_git_current_branch(root_path_buf, &branch) == 0) { + yyjson_mut_obj_add_strcpy(doc, p, "branch", branch); } - cbm_git_context_free(&gctx); + free(branch); } yyjson_mut_obj_add_int(doc, p, "nodes", nodes); yyjson_mut_obj_add_int(doc, p, "edges", edges); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..00973f1e1 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -25,6 +25,7 @@ */ #include "test_framework.h" #include "test_helpers.h" +#include "git/git_command.h" #include "git/git_context.h" #include @@ -61,6 +62,22 @@ static int make_git_repo(const char *dir) { } #endif /* _WIN32 */ +/* Cross-platform setup for branch-only tests. Unlike git_run(), this uses the + * production command formatter and cbm_popen/cbm_pclose lifecycle on Windows + * as well as POSIX. */ +static int make_git_repo_portable(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (cbm_git_drain_command(dir, "init -q") != 0) return -1; + if (cbm_git_drain_command(dir, "config user.email test@example.com") != 0) return -1; + if (cbm_git_drain_command(dir, "config user.name Test") != 0) return -1; + char path[CBM_SZ_1K]; + int n = snprintf(path, sizeof(path), "%s/.keep", dir); + if (n <= 0 || (size_t)n >= sizeof(path)) return -1; + th_write_file(path, ""); + if (cbm_git_drain_command(dir, "add .keep") != 0) return -1; + return cbm_git_drain_command(dir, "commit -q -m init"); +} + /* ── canonical_root: normal repo indexed from its root ──────────── */ TEST(canonical_root_repo_root) { @@ -232,10 +249,76 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +TEST(current_branch_resolves_attached_detached_unborn_and_non_git) { + char repo[256]; + char *raw = th_mktempdir("cbm_branch_repo"); + if (!raw) FAIL("th_mktempdir returned NULL"); + snprintf(repo, sizeof(repo), "%s", raw); + + char non_git[256]; + raw = th_mktempdir("cbm_branch_plain"); + if (!raw) { + th_rmtree(repo); + FAIL("th_mktempdir returned NULL"); + } + snprintf(non_git, sizeof(non_git), "%s", raw); + + bool setup_ok = make_git_repo_portable(repo) == 0 && + cbm_git_drain_command(repo, "checkout -q -b branch-probe") == 0; + char *attached = NULL; + char *detached = NULL; + char *unborn = NULL; + char *plain = NULL; + int attached_rc = setup_ok ? cbm_git_current_branch(repo, &attached) : CBM_NOT_FOUND; + bool detach_ok = setup_ok && cbm_git_drain_command(repo, "checkout -q --detach") == 0; + int detached_rc = detach_ok ? cbm_git_current_branch(repo, &detached) : CBM_NOT_FOUND; + cbm_git_context_t detached_context = {0}; + int detached_context_rc = + detach_ok ? cbm_git_context_resolve(repo, &detached_context) : CBM_NOT_FOUND; + bool unborn_setup_ok = cbm_git_drain_command(non_git, "init -q") == 0 && + cbm_git_drain_command( + non_git, "symbolic-ref HEAD refs/heads/unborn-probe") == 0; + int unborn_rc = + unborn_setup_ok ? cbm_git_current_branch(non_git, &unborn) : CBM_NOT_FOUND; + char plain_dir[256]; + raw = th_mktempdir("cbm_branch_plain_after_unborn"); + bool plain_setup_ok = raw != NULL; + snprintf(plain_dir, sizeof(plain_dir), "%s", raw ? raw : ""); + int plain_rc = plain_setup_ok ? cbm_git_current_branch(plain_dir, &plain) : CBM_NOT_FOUND; + + bool attached_ok = attached_rc == 0 && attached && strcmp(attached, "branch-probe") == 0; + bool detached_ok = detached_rc == 0 && detached && strcmp(detached, "DETACHED") == 0; + bool detached_context_ok = detached_context_rc == 0 && detached_context.is_detached && + detached_context.branch && + strcmp(detached_context.branch, "DETACHED") == 0; + bool unborn_ok = unborn_rc == 0 && unborn && strcmp(unborn, "unborn-probe") == 0; + bool plain_ok = plain_rc == CBM_NOT_FOUND && plain == NULL; + free(attached); + free(detached); + free(unborn); + free(plain); + cbm_git_context_free(&detached_context); + if (plain_setup_ok) th_rmtree(plain_dir); + th_rmtree(non_git); + th_rmtree(repo); + + ASSERT_TRUE(setup_ok); + ASSERT_TRUE(detach_ok); + ASSERT_TRUE(unborn_setup_ok); + ASSERT_TRUE(plain_setup_ok); + ASSERT_TRUE(attached_ok); + ASSERT_TRUE(detached_ok); + ASSERT_TRUE(detached_context_ok); + ASSERT_TRUE(unborn_ok); + ASSERT_TRUE(plain_ok); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(current_branch_resolves_attached_detached_unborn_and_non_git); } diff --git a/tests/test_main.c b/tests/test_main.c index 8b2b5f4d3..6b48a14d9 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -383,6 +383,7 @@ int main(int argc, char **argv) { if (strstr("language", only_suite)) RUN_SUITE(language); if (strstr("userconfig", only_suite)) RUN_SUITE(userconfig); if (strstr("gitignore", only_suite)) RUN_SUITE(gitignore); + if (strstr("git_context", only_suite)) RUN_SUITE(git_context); if (strstr("discover", only_suite)) RUN_SUITE(discover); if (strstr("graph_buffer", only_suite)) RUN_SUITE(graph_buffer); if (strstr("registry", only_suite)) RUN_SUITE(registry); diff --git a/tests/test_security.c b/tests/test_security.c index 926bfba9a..2531631a1 100644 --- a/tests/test_security.c +++ b/tests/test_security.c @@ -618,6 +618,13 @@ TEST(popen_isolates_listening_socket) { #endif /* _WIN32 */ +TEST(pclose_exit_code_normalizes_platform_status) { + FILE *fp = cbm_popen("exit 7", "r"); + ASSERT_NOT_NULL(fp); + ASSERT_EQ(cbm_pclose_exit_code(fp), 7); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * PORTABLE FILE REPLACEMENT * ══════════════════════════════════════════════════════════════════ */ @@ -873,6 +880,7 @@ SUITE(security) { RUN_TEST(popen_isolates_listening_socket); #endif + RUN_TEST(pclose_exit_code_normalizes_platform_status); RUN_TEST(compat_replace_file_replaces_destination); RUN_TEST(compat_move_file_no_replace_preserves_existing_destination); RUN_TEST(compat_move_file_no_replace_moves_when_destination_missing);