Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile.cbm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 37 additions & 3 deletions src/git/git_context.c
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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("");
Expand Down
220 changes: 220 additions & 0 deletions tests/test_git_context.c
Original file line number Diff line number Diff line change
@@ -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 <stdio.h>
#include <string.h>

#ifndef _WIN32
#include <limits.h>
#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 "<repo>/.." or "<subdir>/..". */
#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);
}
2 changes: 2 additions & 0 deletions tests/test_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) */
Expand Down
Loading