From c8184ed94887a6b5e130726885b44a07c63a47b7 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Fri, 11 Sep 2026 21:49:52 +0000 Subject: [PATCH] fix(discover): honor an enclosing repo's .gitignore for a git-less subfolder resolve_git_common_dir() only stats repo_path/.git directly, so indexing a subfolder with no .git of its own never consulted any ancestor repo's .gitignore. Add resolve_enclosing_git_root() to walk up parent directories the way git itself does, and merge that ancestor's .gitignore (and info/exclude) ahead of the subfolder's own so the more specific file still wins on conflict. Fixes #510 Signed-off-by: Amir Fathi --- src/discover/discover.c | 64 ++++++++++++++++++++++++++-- tests/test_discover.c | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/discover/discover.c b/src/discover/discover.c index 5cadaed75..b90edc023 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -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); @@ -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. /.gitignore — committed exclusions - * 2. /info/exclude — per-clone exclusions, not committed + * 1. /.gitignore: enclosing repo's root exclusions, only when + * repo_path itself has no .git of its own (see below) + * 2. /.gitignore: committed exclusions + * 3. /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 + * 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; @@ -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); diff --git a/tests/test_discover.c b/tests/test_discover.c index 0f3dac7d2..ae4d9585d 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -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 /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) { @@ -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);