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
127 changes: 116 additions & 11 deletions src/discover/discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. <repo>/.git is a directory - ordinary repo; common_dir == <repo>/.git
* 2. <repo>/.git is a regular file - linked worktree gitlink "gitdir: <path>";
* the common dir is read from <git_dir>/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: <path>" 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 <git_dir>/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);
Expand All @@ -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. <repo>/.gitignore — committed exclusions
* 2. <repo>/.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. <repo>/.gitignore — committed exclusions
* 2. <common>/info/exclude — per-clone exclusions, not committed
* <common> 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) {
Expand Down
67 changes: 67 additions & 0 deletions tests/test_discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,69 @@ TEST(discover_git_info_exclude_stacks_with_gitignore) {
PASS();
}

/* ── Linked-worktree ignore tests ──────────────────────────────── */

/* In a linked worktree, <repo>/.git is a regular file ("gitdir: <path>"), not a
* directory, and info/exclude + config live in the shared common dir named by
* <gitdir>/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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading