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
64 changes: 60 additions & 4 deletions src/discover/discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,37 @@ static bool resolve_git_common_dir(const char *repo_path, char *common_dir, size
return true;
}

/* When repo_path itself carries no .git (resolve_git_common_dir already
* returned false for it), walk upward looking for an enclosing repository,
* exactly as `git` itself would when run from a subfolder. Bounded by the
* filesystem/drive root: dir is truncated at each iteration, so the loop
* cannot run more times than repo_path is characters long. Returns true and
* fills ancestor_root (for the enclosing repo's own .gitignore) plus
* common_dir (for its info/exclude + config, via the same resolution
* resolve_git_common_dir already applies to an ordinary repo root). Fixes
* the remaining half of issue #510: only the indexed directory's own
* .gitignore was ever consulted, never an enclosing repo's. */
static bool resolve_enclosing_git_root(const char *repo_path, char *ancestor_root, size_t ar_sz,
char *common_dir, size_t cd_sz) {
char dir[CBM_SZ_4K];
snprintf(dir, sizeof(dir), "%s", repo_path);
cbm_normalize_path_sep(dir);

for (;;) {
char *slash = strrchr(dir, '/');
/* No separator left, or only the root separator (POSIX "/") or a
* bare drive prefix (Windows "C:/"): nothing above this to check. */
if (!slash || slash == dir || (slash > dir && *(slash - 1) == ':')) {
return false;
}
*slash = '\0';
if (resolve_git_common_dir(dir, common_dir, cd_sz)) {
snprintf(ancestor_root, ar_sz, "%s", 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 Down Expand Up @@ -1215,11 +1246,13 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc

/* Load gitignore sources for ordinary repos AND linked worktrees.
* Sources merged in order (later patterns win on conflict):
* 1. <repo>/.gitignore — committed exclusions
* 2. <common>/info/exclude — per-clone exclusions, not committed
* 1. <enclosing>/.gitignore: enclosing repo's root exclusions, only when
* repo_path itself has no .git of its own (see below)
* 2. <repo>/.gitignore: committed exclusions
* 3. <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
* just like a normal checkout. All 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;
Expand All @@ -1231,11 +1264,34 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
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;
/* repo_path itself is not a git repo root: walk upward for an enclosing one,
* exactly as `git` does when run from a subfolder. Its root .gitignore is
* loaded FIRST below (least specific), so the indexed directory's own
* .gitignore and info/exclude (both more specific) still override it on
* conflict. Fixes the remaining half of issue #510: only the indexed
* directory's own .gitignore was ever consulted, never an enclosing repo's. */
char enclosing_root[CBM_SZ_4K];
if (!is_git_repo &&
resolve_enclosing_git_root(repo_path, enclosing_root, sizeof(enclosing_root),
git_common_dir, sizeof(git_common_dir))) {
is_git_repo = true;
char enclosing_gi_path[CBM_SZ_4K];
path_join(enclosing_gi_path, sizeof(enclosing_gi_path), enclosing_root, ".gitignore");
gitignore = cbm_gitignore_load(enclosing_gi_path);
}
/* 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).
* 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);
cbm_gitignore_t *local_gitignore = cbm_gitignore_load(gi_path);
if (local_gitignore) {
if (!gitignore) {
gitignore = local_gitignore;
} else {
(void)cbm_gitignore_merge(gitignore, local_gitignore);
cbm_gitignore_free(local_gitignore);
}
}
if (is_git_repo) {
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);
Expand Down
92 changes: 92 additions & 0 deletions tests/test_discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,93 @@ TEST(discover_worktree_committed_gitignore) {
PASS();
}

/* ── Enclosing-repo .gitignore tests (issue #510, second half) ──── */

/* repo_path itself has no .git (indexing a git-less subfolder of a larger
* repo). The enclosing repo's root .gitignore must still be honored, exactly
* as `git status`/`git check-ignore` run from that subfolder would. Before
* this fix, resolve_git_common_dir() only ever stat'd repo_path/.git
* directly and gave up, so the enclosing repo's rules were silently never
* consulted. */
TEST(discover_enclosing_repo_gitignore_issue510) {
char *base = th_mktempdir("cbm_disc_enc_gi");
ASSERT(base != NULL);

th_mkdir_p(TH_PATH(base, ".git"));
th_write_file(TH_PATH(base, ".gitignore"), "secret.py\n");
th_write_file(TH_PATH(base, "pkg/secret.py"), "TOKEN = 1\n");
th_write_file(TH_PATH(base, "pkg/keep.py"), "pass\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(TH_PATH(base, "pkg"), &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(strstr(files[0].rel_path, "keep.py") != NULL);
ASSERT_FALSE(discover_has_rel_path(files, count, "secret.py"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

/* The enclosing repo's <common>/info/exclude (per-clone, uncommitted) must
* be honored the same way once the enclosing root is found, exactly as it
* already is for a repo_path that carries its own .git (issue #489). */
TEST(discover_enclosing_repo_info_exclude) {
char *base = th_mktempdir("cbm_disc_enc_exc");
ASSERT(base != NULL);

th_mkdir_p(TH_PATH(base, ".git/info"));
th_write_file(TH_PATH(base, ".git/info/exclude"), "scratch/\n");
th_write_file(TH_PATH(base, "pkg/main.py"), "pass\n");
th_write_file(TH_PATH(base, "pkg/scratch/tmp.py"), "pass\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(TH_PATH(base, "pkg"), &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(strstr(files[0].rel_path, "main.py") != NULL);

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

/* Precedence: the indexed directory's own .gitignore is more specific than
* the enclosing repo's root .gitignore and must still win on conflict,
* matching git's shallow-to-deep rule (a later, deeper pattern overrides an
* earlier, shallower one). Without this, folding the enclosing root in
* ahead of repo_path's own .gitignore in the wrong order would let a root
* pattern silently re-ignore a file the subfolder's own .gitignore
* un-ignores. */
TEST(discover_enclosing_repo_gitignore_local_overrides) {
char *base = th_mktempdir("cbm_disc_enc_gi_ovr");
ASSERT(base != NULL);

th_mkdir_p(TH_PATH(base, ".git"));
th_write_file(TH_PATH(base, ".gitignore"), "*.py\n");
th_write_file(TH_PATH(base, "pkg/.gitignore"), "!keep.py\n");
th_write_file(TH_PATH(base, "pkg/keep.py"), "pass\n");
th_write_file(TH_PATH(base, "pkg/drop.py"), "pass\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(TH_PATH(base, "pkg"), &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(strstr(files[0].rel_path, "keep.py") != NULL);
ASSERT_FALSE(discover_has_rel_path(files, count, "drop.py"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

/* ── Nested .gitignore tests (issue #178) ──────────────────────── */

TEST(discover_nested_gitignore) {
Expand Down Expand Up @@ -1971,6 +2058,11 @@ SUITE(discover) {
RUN_TEST(discover_worktree_info_exclude);
RUN_TEST(discover_worktree_committed_gitignore);

/* Enclosing-repo .gitignore resolution (issue #510, second half) */
RUN_TEST(discover_enclosing_repo_gitignore_issue510);
RUN_TEST(discover_enclosing_repo_info_exclude);
RUN_TEST(discover_enclosing_repo_gitignore_local_overrides);

/* Nested .gitignore tests (issue #178) */
RUN_TEST(discover_nested_gitignore);
RUN_TEST(discover_nested_gitignore_stacks_with_root);
Expand Down
Loading