diff --git a/Makefile.cbm b/Makefile.cbm index f52d4fce..71293460 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -328,6 +328,7 @@ TEST_DISCOVER_SRCS = \ tests/test_language.c \ tests/test_userconfig.c \ tests/test_gitignore.c \ + tests/test_git_context.c \ tests/test_discover.c TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c diff --git a/src/git/git_context.c b/src/git/git_context.c index 99ae17cf..46dbe8f8 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -126,17 +126,51 @@ static char *join_root_relative(const char *root, const char *rel) { return out; } -static char *derive_canonical_root(const char *worktree_root, const char *git_common_dir) { +static char *derive_canonical_root(const char *input_path, const char *worktree_root, + const char *git_common_dir) { const char *src = git_common_dir && git_common_dir[0] ? git_common_dir : worktree_root; if (!src) { return git_strdup(""); } - char *root = path_is_absolute(src) ? git_strdup(src) : join_root_relative(worktree_root, src); + /* git rev-parse --git-common-dir outputs a path relative to the directory + * passed via -C (input_path), NOT to worktree_root. Using worktree_root as + * the base was wrong when input_path is a subdirectory or a linked worktree + * sibling — e.g. "../.git" joined with worktree_root produced "workspace/.." + * instead of the actual repo root. */ + char *root = path_is_absolute(src) ? git_strdup(src) : join_root_relative(input_path, src); if (!root) { return NULL; } + /* Resolve ".." components before stripping the "/.git" suffix. + * Without this, "/workspace/scripts/../.git" would strip to + * "/workspace/scripts/.." instead of "/workspace". The .git directory + * must exist for a valid git repo, so realpath() / _fullpath() succeeds. */ +#ifndef _WIN32 + { + char resolved[4096]; + if (realpath(root, resolved) != NULL) { + free(root); + root = git_strdup(resolved); + if (!root) { + return NULL; + } + } + } +#else + { + char resolved[4096]; + if (_fullpath(resolved, root, sizeof(resolved)) != NULL) { + free(root); + root = git_strdup(resolved); + if (!root) { + return NULL; + } + } + } +#endif + size_t len = strlen(root); while (len > 1 && (root[len - 1] == '/' || root[len - 1] == '\\')) { root[--len] = '\0'; @@ -251,7 +285,7 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { out->is_worktree = out->git_dir && out->git_common_dir && strcmp(out->git_dir, out->git_common_dir) != 0; - out->canonical_root = derive_canonical_root(out->worktree_root, out->git_common_dir); + out->canonical_root = derive_canonical_root(path, out->worktree_root, out->git_common_dir); out->branch_slug = slug_from_branch(out->branch, out->is_detached); if (git_capture(path, "merge-base HEAD @{upstream}", &out->base_sha) != 0) { out->base_sha = git_strdup(""); diff --git a/tests/test_git_context.c b/tests/test_git_context.c new file mode 100644 index 00000000..d9bb2977 --- /dev/null +++ b/tests/test_git_context.c @@ -0,0 +1,220 @@ +/* + * test_git_context.c — Tests for cbm_git_context_resolve(), focusing on + * the canonical_root derivation for git worktrees and subdirectory projects. + * + * Issue #659: canonical_root was computed incorrectly for linked worktrees + * and projects indexed from a subdirectory of the repository root. + * git rev-parse --git-common-dir outputs a path relative to the -C directory + * (input_path), not to worktree_root. Joining it with worktree_root and then + * string-stripping "/.git" left unresolved ".." components in the result. + */ +#include "test_framework.h" +#include "test_helpers.h" +#include "git/git_context.h" + +#include +#include + +#ifndef _WIN32 +#include +#endif + +/* Run a git command inside dir, return 0 on success. */ +static int 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); +} + +/* Create a minimal git repo at dir (init + empty commit so HEAD exists). */ +static int make_git_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (git_run(dir, "init -q") != 0) return -1; + if (git_run(dir, "config user.email test@example.com") != 0) return -1; + if (git_run(dir, "config user.name Test") != 0) return -1; + /* Create a file so HEAD points to a real commit. */ + char path[1024]; + snprintf(path, sizeof(path), "%s/.keep", dir); + th_write_file(path, ""); + if (git_run(dir, "add .keep") != 0) return -1; + if (git_run(dir, "commit -q -m init") != 0) return -1; + return 0; +} + +/* ── canonical_root: normal repo indexed from its root ──────────── */ + +TEST(canonical_root_repo_root) { + char *tmp = th_mktempdir("cbm_gitctx"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (make_git_repo(tmp) != 0) { + th_rmtree(tmp); + FAIL("failed to init git repo"); + } + + cbm_git_context_t ctx = {0}; + int rc = cbm_git_context_resolve(tmp, &ctx); + if (rc != 0 || !ctx.is_git) { + cbm_git_context_free(&ctx); + th_rmtree(tmp); + FAIL("cbm_git_context_resolve failed or not a git repo"); + } + +#ifndef _WIN32 + char expected[4096]; + if (realpath(tmp, expected) == NULL) { + cbm_git_context_free(&ctx); + th_rmtree(tmp); + FAIL("realpath(tmp) failed"); + } +#else + char expected[4096]; + if (_fullpath(expected, tmp, sizeof(expected)) == NULL) { + cbm_git_context_free(&ctx); + th_rmtree(tmp); + FAIL("_fullpath(tmp) failed"); + } +#endif + + ASSERT_STR_EQ(ctx.canonical_root, expected); + + cbm_git_context_free(&ctx); + th_rmtree(tmp); + PASS(); +} + +/* ── canonical_root: indexed from a subdirectory (issue #659) ───── */ + +TEST(canonical_root_subdir) { + char *tmp = th_mktempdir("cbm_gitctx"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (make_git_repo(tmp) != 0) { + th_rmtree(tmp); + FAIL("failed to init git repo"); + } + + /* Create a subdirectory inside the repo. */ + char subdir[1024]; + snprintf(subdir, sizeof(subdir), "%s/scripts", tmp); + if (th_mkdir_p(subdir) != 0) { + th_rmtree(tmp); + FAIL("failed to create subdir"); + } + + cbm_git_context_t ctx = {0}; + int rc = cbm_git_context_resolve(subdir, &ctx); + if (rc != 0 || !ctx.is_git) { + cbm_git_context_free(&ctx); + th_rmtree(tmp); + FAIL("cbm_git_context_resolve on subdir failed or not a git repo"); + } + + /* canonical_root must equal the repo root, NOT "/.." or "/..". */ +#ifndef _WIN32 + char expected[4096]; + if (realpath(tmp, expected) == NULL) { + cbm_git_context_free(&ctx); + th_rmtree(tmp); + FAIL("realpath(tmp) failed"); + } +#else + char expected[4096]; + if (_fullpath(expected, tmp, sizeof(expected)) == NULL) { + cbm_git_context_free(&ctx); + th_rmtree(tmp); + FAIL("_fullpath(tmp) failed"); + } +#endif + + ASSERT_STR_EQ(ctx.canonical_root, expected); + + /* Sanity: canonical_root must not contain ".." or end with a slash. */ + ASSERT(strstr(ctx.canonical_root, "..") == NULL); + ASSERT(ctx.canonical_root[strlen(ctx.canonical_root) - 1] != '/'); + + cbm_git_context_free(&ctx); + th_rmtree(tmp); + PASS(); +} + +/* ── canonical_root: linked git worktree (issue #659 primary case) ─ */ + +TEST(canonical_root_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_worktree"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + + /* Remove the worktree dir first — git worktree add creates it. */ + th_rmtree(wt_tmp); + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + FAIL("failed to init main git repo"); + } + + /* Create a branch for the worktree. */ + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + + /* Add a linked worktree. */ + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + FAIL("git worktree add failed (git 2.5+ required)"); + } + + cbm_git_context_t ctx = {0}; + int rc = cbm_git_context_resolve(wt_tmp, &ctx); + if (rc != 0 || !ctx.is_git) { + cbm_git_context_free(&ctx); + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + FAIL("cbm_git_context_resolve on linked worktree failed"); + } + + /* canonical_root must be the MAIN repo root, not the worktree root or its parent. */ + char expected[4096]; + if (realpath(main_tmp, expected) == NULL) { + cbm_git_context_free(&ctx); + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + FAIL("realpath(main_tmp) failed"); + } + + ASSERT_STR_EQ(ctx.canonical_root, expected); + ASSERT(strstr(ctx.canonical_root, "..") == NULL); + + cbm_git_context_free(&ctx); + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + PASS(); +#endif /* _WIN32 */ +} + +/* ── Suite ──────────────────────────────────────────────────────── */ + +SUITE(git_context) { + RUN_TEST(canonical_root_repo_root); + RUN_TEST(canonical_root_subdir); + RUN_TEST(canonical_root_linked_worktree); +} diff --git a/tests/test_main.c b/tests/test_main.c index ee2d8a9d..8c9aa9e6 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -34,6 +34,7 @@ extern void suite_mcp(void); extern void suite_language(void); extern void suite_userconfig(void); extern void suite_gitignore(void); +extern void suite_git_context(void); extern void suite_discover(void); extern void suite_graph_buffer(void); extern void suite_registry(void); @@ -147,6 +148,7 @@ int main(void) { RUN_SUITE(language); RUN_SUITE(userconfig); RUN_SUITE(gitignore); + RUN_SUITE(git_context); RUN_SUITE(discover); /* Graph Buffer (M7) */