diff --git a/src/discover/discover.c b/src/discover/discover.c index a43b44be3..6c30b3e62 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -737,6 +737,107 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis /* ── Public API ───────────────────────────────── */ +static bool discover_path_is_absolute(const char *path) { + if (!path || !path[0]) { + return false; + } + if (path[0] == '/' || path[0] == '\\') { + return true; + } +#ifdef _WIN32 + return isalpha((unsigned char)path[0]) && path[1] == ':'; +#else + return false; +#endif +} + +/* Resolve the shared "common" git directory for repo_path. + * Handles three layouts: + * 1. /.git is a directory - ordinary repo; common_dir == /.git + * 2. /.git is a regular file - linked worktree gitlink "gitdir: "; + * the common dir is read from /commondir (git stores info/exclude + + * config there, shared across worktrees). Falls back to git_dir when no + * commondir file exists. + * 3. neither - not a git repo. + * Returns true when a git dir was resolved. Fixes the worktree case where + * .git/info/exclude and core.excludesfile were silently dropped because the old + * check required .git to be a directory (issue #489 only covered ordinary repos). */ +static bool resolve_git_common_dir(const char *repo_path, char *common_dir, size_t cd_sz) { + char dot_git[CBM_SZ_4K]; + snprintf(dot_git, sizeof(dot_git), "%s/.git", repo_path); + struct stat st; + if (wide_stat(dot_git, &st) != 0) { + return false; + } + if (S_ISDIR(st.st_mode)) { + snprintf(common_dir, cd_sz, "%s", dot_git); + cbm_normalize_path_sep(common_dir); + return true; + } + if (!S_ISREG(st.st_mode)) { + return false; + } + + /* Linked worktree: parse "gitdir: " from the gitlink file. */ + FILE *f = fopen(dot_git, "r"); + if (!f) { + return false; + } + char git_dir[CBM_SZ_4K]; + bool got_git_dir = false; + char line[CBM_SZ_4K]; + while (fgets(line, sizeof(line), f)) { + char *gs = trim_ws(line); + if (strncmp(gs, "gitdir:", 7) == 0) { + char *val = trim_ws(gs + 7); + if (val[0] != '\0') { + if (discover_path_is_absolute(val)) { + snprintf(git_dir, sizeof(git_dir), "%s", val); + cbm_normalize_path_sep(git_dir); + } else { + path_join(git_dir, sizeof(git_dir), repo_path, val); + } + got_git_dir = true; + } + break; + } + } + fclose(f); + if (!got_git_dir) { + return false; + } + + /* The shared dir holding info/exclude + config is named in /commondir + * (typically a relative path like "../.."). Absent in single-worktree gitdirs. */ + char commondir_path[CBM_SZ_4K]; + path_join(commondir_path, sizeof(commondir_path), git_dir, "commondir"); + FILE *cf = fopen(commondir_path, "r"); + if (cf) { + char cbuf[CBM_SZ_4K]; + bool resolved = false; + if (fgets(cbuf, sizeof(cbuf), cf)) { + char *cs = trim_ws(cbuf); + if (cs[0] != '\0') { + if (discover_path_is_absolute(cs)) { + snprintf(common_dir, cd_sz, "%s", cs); + cbm_normalize_path_sep(common_dir); + } else { + path_join(common_dir, cd_sz, git_dir, cs); + } + resolved = true; + } + } + fclose(cf); + if (resolved) { + return true; + } + } + + snprintf(common_dir, cd_sz, "%s", git_dir); + cbm_normalize_path_sep(common_dir); + return true; +} + int cbm_discover(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out, int *count) { return cbm_discover_ex(repo_path, opts, out, count, NULL, NULL); @@ -763,31 +864,35 @@ int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_ return CBM_NOT_FOUND; } - /* Load gitignore sources when a .git directory is present. + /* Load gitignore sources for ordinary repos AND linked worktrees. * Sources merged in order (later patterns win on conflict): - * 1. /.gitignore — committed exclusions - * 2. /.git/info/exclude — per-clone exclusions, not committed - * Both are folded into a single matcher so all downstream call paths - * remain unchanged. Fixes issue #489: OOM on repos whose worktrees - * are excluded only via .git/info/exclude (e.g. Sandcastle). */ + * 1. /.gitignore — committed exclusions + * 2. /info/exclude — per-clone exclusions, not committed + * is the git common dir, resolved via resolve_git_common_dir() so a + * worktree (where .git is a gitlink file) reads the shared info/exclude/config + * just like a normal checkout. Both are folded into a single matcher so all + * downstream call paths remain unchanged. Fixes issue #489: OOM on repos whose + * worktrees are excluded only via .git/info/exclude (e.g. Sandcastle). */ cbm_gitignore_t *gitignore = NULL; char gi_path[CBM_SZ_4K]; - snprintf(gi_path, sizeof(gi_path), "%s/.git", repo_path); struct stat gi_stat; - bool is_git_repo = wide_stat(gi_path, &gi_stat) == 0 && S_ISDIR(gi_stat.st_mode); + /* Resolve the git common dir, transparently following a worktree gitlink so the + * .git/info/exclude and core.excludesfile sources are honoured inside linked + * worktrees too (where .git is a file pointing at the shared dir, not a directory). */ + char git_common_dir[CBM_SZ_4K]; + bool is_git_repo = resolve_git_common_dir(repo_path, git_common_dir, sizeof(git_common_dir)); bool has_git_config = false; /* Always honour the .gitignore at the indexed-directory root, even when the * directory is not a git repo root (e.g. indexing a sub-package directly). - * The .git/info/exclude and global-excludes sources still require .git/. * Fixes issue #510: a root .gitignore was silently ignored without .git/. */ snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", repo_path); gitignore = cbm_gitignore_load(gi_path); if (is_git_repo) { - snprintf(gi_path, sizeof(gi_path), "%s/.git/config", repo_path); + path_join(gi_path, sizeof(gi_path), git_common_dir, "config"); has_git_config = wide_stat(gi_path, &gi_stat) == 0 && S_ISREG(gi_stat.st_mode); char exc_path[CBM_SZ_4K]; - snprintf(exc_path, sizeof(exc_path), "%s/.git/info/exclude", repo_path); + path_join(exc_path, sizeof(exc_path), git_common_dir, "info/exclude"); cbm_gitignore_t *git_exclude = cbm_gitignore_load(exc_path); if (git_exclude) { if (!gitignore) { diff --git a/tests/test_discover.c b/tests/test_discover.c index dff7e61c9..e363c28fe 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -932,6 +932,69 @@ TEST(discover_git_info_exclude_stacks_with_gitignore) { PASS(); } +/* ── Linked-worktree ignore tests ──────────────────────────────── */ + +/* In a linked worktree, /.git is a regular file ("gitdir: "), not a + * directory, and info/exclude + config live in the shared common dir named by + * /commondir. The discover step must follow the gitlink so per-clone + * excludes are honored exactly as in an ordinary checkout. Regression for the + * worktree gap left by issue #489 (which only handled .git as a directory). */ +TEST(discover_worktree_info_exclude) { + char *base = th_mktempdir("cbm_disc_wt_exc"); + ASSERT(base != NULL); + + /* Worktree gitlink -> per-worktree gitdir (relative to the worktree root). */ + th_write_file(TH_PATH(base, ".git"), "gitdir: maingit/worktrees/wt\n"); + /* Per-worktree gitdir points back to the shared common dir via commondir. */ + th_mkdir_p(TH_PATH(base, "maingit/worktrees/wt")); + th_write_file(TH_PATH(base, "maingit/worktrees/wt/commondir"), "../..\n"); + /* Shared exclude lives in the common dir, not the per-worktree gitdir. */ + th_mkdir_p(TH_PATH(base, "maingit/info")); + th_write_file(TH_PATH(base, "maingit/info/exclude"), "build/\n"); + + th_write_file(TH_PATH(base, "src/main.go"), "package main\n"); + th_write_file(TH_PATH(base, "build/gen.go"), "package build\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + int rc = cbm_discover(base, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 1); + ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + +/* Committed .gitignore must still apply in a worktree even when commondir is + * absent (older gitdir layouts): the gitlink resolver falls back to the gitdir + * itself, and the root .gitignore is loaded unconditionally (issue #510). */ +TEST(discover_worktree_committed_gitignore) { + char *base = th_mktempdir("cbm_disc_wt_gi"); + ASSERT(base != NULL); + + th_write_file(TH_PATH(base, ".git"), "gitdir: maingit/worktrees/wt\n"); + th_mkdir_p(TH_PATH(base, "maingit/worktrees/wt")); + th_write_file(TH_PATH(base, ".gitignore"), "build/\n"); + + th_write_file(TH_PATH(base, "src/main.go"), "package main\n"); + th_write_file(TH_PATH(base, "build/gen.go"), "package build\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + int rc = cbm_discover(base, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 1); + ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + /* ── Nested .gitignore tests (issue #178) ──────────────────────── */ TEST(discover_nested_gitignore) { @@ -1109,6 +1172,10 @@ SUITE(discover) { RUN_TEST(discover_git_info_exclude); RUN_TEST(discover_git_info_exclude_stacks_with_gitignore); + /* Linked-worktree ignore resolution (gitlink + commondir) */ + RUN_TEST(discover_worktree_info_exclude); + RUN_TEST(discover_worktree_committed_gitignore); + /* Nested .gitignore tests (issue #178) */ RUN_TEST(discover_nested_gitignore); RUN_TEST(discover_nested_gitignore_stacks_with_root);