From c8184ed94887a6b5e130726885b44a07c63a47b7 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Fri, 11 Sep 2026 21:49:52 +0000 Subject: [PATCH 1/2] 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); From 282fbfcdabfec79c19c36f5bec5f75c22eb36df5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 23 Sep 2026 22:41:07 +0200 Subject: [PATCH 2/2] fix(discover): match an enclosing repository's ignore rules relative to their own directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit found the enclosing repository when a git-less subfolder is indexed, but merged its patterns into the matcher whose rel_path is relative to repo_path. gitignore.c matches a rooted pattern with `glob_match_bounded(p->pattern, rel_path)`, so every anchored pattern of the enclosing repository was silently re-anchored onto the indexed subfolder. That is wrong in both directions, and both were checked against real `git check-ignore` run inside the subfolder: * enclosing root .gitignore `/secret.py`, indexing pkg/ git INDEXES pkg/secret.py — we HID it. A rooted pattern means "in the repository root", and re-anchored it became "in pkg". Silent index loss: the file is gone from the graph with no diagnostic, the worst failure mode discovery has. * enclosing .git/info/exclude `pkg/scratch/`, indexing pkg/ git IGNORES pkg/scratch/tmp.py — we INDEXED it. The pattern is anchored THROUGH pkg, so once rel_path lost the "pkg/" prefix it stopped matching and an excluded directory got walked. Only the enclosing root's .gitignore was consulted, too; git reads the .gitignore of every directory between the repository root and the indexed one, deeper overriding shallower. Mechanism. discover.c already had the chain that encodes "the deepest file with an opinion wins": gitignore_link_t carries a `prefix`, the walk-relative directory a nested matcher came from, which local_rel_path() strips off. An ancestor is the mirror image of that, so the chain is extended rather than duplicated: each link now also carries a `base`, the walk root's path relative to the directory the matcher came from, which gitignore_chain_result PREPENDS instead of stripping (discover.c:569, 610). Exactly one of the two offsets is ever non-empty, and both live in the link's flexible tail, so no allocation site is added. * ancestor_ignores_build() (discover.c:1282) loads the .gitignore of every directory from the enclosing repository root down to — but not including — the indexed one, shallow to deep, and chains them so the deepest wins, negations included. The enclosing repository's info/exclude is merged into the ROOT ancestor's matcher, which is where git anchors it, keeping the precedence info/exclude already has over .gitignore at the same level. * the indexed directory's own .gitignore stays its own matcher at the leaf of that chain (discover.c:1480), so it still overrides every ancestor. Nothing is merged into it any more, which also removes the earlier merge's failure mode of dropping the local patterns and keeping the ancestor's. * repo_path is canonicalized with cbm_canonical_path() before the lexical parent walk (discover.c:1414), so `cbm index pkg` and `cbm index /abs/path/pkg` discover the same files; pipeline.c strdups the caller's path unchanged. * walk_owned_gitignore_free() (discover.c:1019) is factored out of walk_dir and shared with the ancestor chain, which owns its matchers the same way. Proof on real input, before the suites. Indexing scripts/ of this checkout — a git-less subfolder whose root .gitignore carries the rooted patterns `/memlab-*` and `/soak*/`: before 87 files memlab-drive.py MISSING memlab-report.py MISSING after 89 files memlab-drive.py present memlab-report.py present `git check-ignore` inside scripts/ indexes both, so the two recovered files are exactly the ones git keeps. No file is lost in the other direction. RED on the previous commit, four new tests, each direction pinned to a `git check-ignore` verdict on an identical fixture: test_discover.c:1471: count == 1, expected 2 == 2 rooted_pattern_not_reanchored test_discover.c:1498: count == 2, expected 1 == 1 info_exclude_rooted_subpath test_discover.c:1526: count == 2, expected 1 == 1 intermediate_gitignore test_discover.c:1555: count == 0, expected 1 == 1 deeper_ancestor_negation_wins discover: 120 passed, 4 failed The three tests already on the branch use unanchored patterns, which match at any depth and so cannot see this bug; they pass before and after. GREEN: discover 124 passed, 0 failed; discover + gitignore + pipeline + git_context 425 passed, 0 failed. Revert-check: reverting only the discover.c hunks and rebuilding puts the four new tests RED again on the same four lines with the same counts (120 passed, 4 failed); restoring returns 425 passed. Memory-core linter: none grew (31 raw sites in discover.c before and after). clang-format clean. Co-authored-by: Amir Fathi Signed-off-by: Martin Vogel --- src/discover/discover.c | 229 ++++++++++++++++++++++++++++++++-------- tests/test_discover.c | 123 +++++++++++++++++++++ 2 files changed, 309 insertions(+), 43 deletions(-) diff --git a/src/discover/discover.c b/src/discover/discover.c index b90edc023..c10d345f1 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -554,26 +554,40 @@ static const char *local_rel_path(const char *rel_path, const char *local_prefix * matcher borrows a pointer to the deepest link governing it, so a directory * without a .gitignore of its own simply shares its parent's link. Links live * on the heap (the frame stack is realloc'd and popped) and walk_dir owns them - * through the `owned_next` list. `prefix` is the walk-relative directory the - * matcher was loaded from ("" for the root). */ + * through the `owned_next` list. + * + * Every pattern is anchored to the directory its file came from, so each link + * carries the offset between that directory and the walk-relative rel_path it + * is asked about. The two offsets are mirror images and exactly one is ever + * non-empty: + * `prefix` — the matcher's directory is at or BELOW the walk root + * ("" for the root itself): strip it from rel_path. + * `base` — the matcher's directory is ABOVE the walk root (an enclosing + * repository's ignore files, when a git-less subfolder is + * indexed): prepend the walk root's path relative to it. + * Both strings live in the flexible tail, `base` right after `prefix`. */ typedef struct gitignore_link { const cbm_gitignore_t *gi; const struct gitignore_link *parent; struct gitignore_link *owned_next; + const char *base; char prefix[]; } gitignore_link_t; static gitignore_link_t *gitignore_link_new(const cbm_gitignore_t *gi, const char *prefix, - const gitignore_link_t *parent, + const char *base, const gitignore_link_t *parent, gitignore_link_t **owned_links) { size_t prefix_size = strlen(prefix) + SKIP_ONE; - gitignore_link_t *link = malloc(sizeof(*link) + prefix_size); + size_t base_size = strlen(base) + SKIP_ONE; + gitignore_link_t *link = malloc(sizeof(*link) + prefix_size + base_size); if (!link) { return NULL; } link->gi = gi; link->parent = parent; memcpy(link->prefix, prefix, prefix_size); + memcpy(link->prefix + prefix_size, base, base_size); + link->base = link->prefix + prefix_size; link->owned_next = *owned_links; *owned_links = link; return link; @@ -594,9 +608,20 @@ static void gitignore_links_free(gitignore_link_t *link) { * re-included by a negation, 0 when no file mentions the path. Cost is one * match per .gitignore on the path — O(depth), never a rescan. */ static int gitignore_chain_result(const gitignore_link_t *link, const char *rel_path, bool is_dir) { + char based[CBM_SZ_4K]; for (; link; link = link->parent) { - int verdict = - cbm_gitignore_match_result(link->gi, local_rel_path(rel_path, link->prefix), is_dir); + const char *probe = local_rel_path(rel_path, link->prefix); + if (link->base[0]) { + /* Matcher from a directory above the walk root: ask it about the + * path it would see, i.e. the one its own rooted patterns are + * anchored against. */ + int written = snprintf(based, sizeof(based), "%s/%s", link->base, probe); + if (written < 0 || (size_t)written >= sizeof(based)) { + continue; /* longer than this matcher can address: no opinion */ + } + probe = based; + } + int verdict = cbm_gitignore_match_result(link->gi, probe, is_dir); if (verdict != 0) { return verdict; } @@ -988,9 +1013,20 @@ static bool walk_owned_gitignore_append(cbm_gitignore_t ***owned, size_t *count, return true; } +/* Release an owner array built by walk_owned_gitignore_append(). Shared by the + * walk and by the enclosing-repository chain, which own their matchers the + * same way. */ +static void walk_owned_gitignore_free(cbm_gitignore_t **owned, size_t count) { + for (size_t i = 0; i < count; i++) { + cbm_gitignore_free(owned[i]); + } + free(owned); +} + static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts, - const cbm_gitignore_t *gitignore, const cbm_gitignore_t *global_gi, - const cbm_gitignore_t *cbmignore, file_list_t *out) { + const cbm_gitignore_t *gitignore, const gitignore_link_t *ancestors, + const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore, + file_list_t *out) { walk_stack_t ws = { .frames = calloc(WALK_STACK_CAP, sizeof(walk_frame_t)), .top = 0, .cap = WALK_STACK_CAP}; if (!ws.frames) { @@ -1012,8 +1048,12 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis free(ws.frames); return; } + /* `ancestors` is the deepest link of the enclosing repository's chain (or + * NULL); the walk root's own matcher sits below it so it still wins. */ + ws.frames[0].ignore_chain = ancestors; if (gitignore) { - ws.frames[0].ignore_chain = gitignore_link_new(gitignore, rel_prefix, NULL, &owned_links); + ws.frames[0].ignore_chain = + gitignore_link_new(gitignore, rel_prefix, "", ancestors, &owned_links); if (!ws.frames[0].ignore_chain) { out->failed = true; free(ws.frames); @@ -1034,7 +1074,7 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis } /* owned_gis owns `loaded` from here on, even if the link fails. */ const gitignore_link_t *link = - gitignore_link_new(loaded, frame.prefix, frame.ignore_chain, &owned_links); + gitignore_link_new(loaded, frame.prefix, "", frame.ignore_chain, &owned_links); if (!link) { out->failed = true; break; @@ -1056,10 +1096,7 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis } cbm_closedir(d); } - for (size_t i = 0; i < owned_count; i++) { - cbm_gitignore_free(owned_gis[i]); - } - free(owned_gis); + walk_owned_gitignore_free(owned_gis, owned_count); gitignore_links_free(owned_links); free(ws.frames); } @@ -1199,6 +1236,102 @@ static bool resolve_enclosing_git_root(const char *repo_path, char *ancestor_roo } } +/* The enclosing repository's ignore files, kept OUT of the indexed directory's + * own matcher. Merging them in would re-anchor every rooted pattern onto the + * indexed subfolder: "/secret.py" at the enclosing root would start hiding + * /secret.py (silent index loss), and "pkg/scratch/" would stop + * hiding pkg/scratch (a directory git excludes would be walked). Each matcher + * instead keeps its own directory as its anchor, via the link's `base`. */ +typedef struct { + cbm_gitignore_t **owned; + size_t count; + size_t capacity; + const gitignore_link_t *chain; /* deepest ancestor link, or NULL */ + gitignore_link_t *links; /* owner list backing `chain` */ +} ancestor_ignores_t; + +static void ancestor_ignores_free(ancestor_ignores_t *anc) { + walk_owned_gitignore_free(anc->owned, anc->count); + gitignore_links_free(anc->links); +} + +/* Add one ancestor directory's matcher to the chain, deepest last so that the + * deepest file with an opinion wins (git's shallow-to-deep rule, negations + * included). Takes ownership of `gi`. */ +static bool ancestor_ignores_push(ancestor_ignores_t *anc, cbm_gitignore_t *gi, const char *base) { + if (!walk_owned_gitignore_append(&anc->owned, &anc->count, &anc->capacity, gi)) { + cbm_gitignore_free(gi); + return false; + } + /* owned[] owns `gi` from here on, even if the link fails. */ + const gitignore_link_t *link = gitignore_link_new(gi, "", base, anc->chain, &anc->links); + if (!link) { + return false; + } + anc->chain = link; + return true; +} + +/* Load the .gitignore of EVERY directory from the enclosing repository root + * (inclusive) down to the indexed directory (exclusive) — git consults all of + * them, not just the root's. `root_extra` is that repository's + * /info/exclude, which git anchors at its root like the root + * .gitignore; ownership is taken. `leaf` must be a canonical path under + * `root`, both with '/' separators. Returns false only on allocation + * failure. */ +static bool ancestor_ignores_build(ancestor_ignores_t *anc, const char *root, const char *leaf, + cbm_gitignore_t *root_extra) { + size_t root_len = strlen(root); + if (strncmp(leaf, root, root_len) != 0) { + cbm_gitignore_free(root_extra); + return true; + } + const char *base = leaf + root_len; + while (*base == '/') { + base++; + } + + char dir[CBM_SZ_4K]; + int dir_len = snprintf(dir, sizeof(dir), "%s", root); + if (dir_len < 0 || (size_t)dir_len >= sizeof(dir) || !*base) { + cbm_gitignore_free(root_extra); + return dir_len >= 0 && (size_t)dir_len < sizeof(dir); + } + + for (;;) { + char gi_path[CBM_SZ_4K]; + path_join(gi_path, sizeof(gi_path), dir, ".gitignore"); + cbm_gitignore_t *gi = cbm_gitignore_load(gi_path); + if (root_extra) { + /* First iteration: `dir` is the enclosing repository root, the one + * directory info/exclude shares an anchor with. Merged after the + * .gitignore patterns so it overrides them, the same precedence + * the two already have for an ordinary repository root. */ + if (!gi) { + gi = root_extra; + } else { + (void)cbm_gitignore_merge(gi, root_extra); + cbm_gitignore_free(root_extra); + } + root_extra = NULL; + } + if (gi && !ancestor_ignores_push(anc, gi, base)) { + return false; + } + const char *slash = strchr(base, '/'); + if (!slash) { + return true; /* the next directory down IS the indexed one */ + } + int written = snprintf(dir + dir_len, sizeof(dir) - (size_t)dir_len, "/%.*s", + (int)(slash - base), base); + if (written < 0 || (size_t)written >= sizeof(dir) - (size_t)dir_len) { + return true; /* deeper directories are unaddressable: stop here */ + } + dir_len += written; + base = slash + SKIP_ONE; + } +} + 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); @@ -1246,15 +1379,18 @@ 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: 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 + * 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. All are folded into a single matcher so all + * 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). */ + * worktrees are excluded only via .git/info/exclude (e.g. Sandcastle). + * + * An ENCLOSING repository's ignore files (indexing a git-less subfolder, + * issue #510) are deliberately NOT folded in here: their patterns are + * anchored to their own directories, so they stay separate matchers on the + * chain with a `base` offset. See ancestor_ignores_build(). */ cbm_gitignore_t *gitignore = NULL; char gi_path[CBM_SZ_4K]; struct stat gi_stat; @@ -1265,33 +1401,30 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc 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. */ + * exactly as `git` does when run from a subfolder. Fixes the remaining half + * of issue #510: only the indexed directory's own .gitignore was ever + * consulted, never an enclosing repo's. The walk is lexical, so repo_path is + * canonicalized first — otherwise `cbm index pkg` and `cbm index /abs/pkg` + * would discover different files from the same directory. */ + ancestor_ignores_t ancestors = {0}; 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); + char canonical_repo[CBM_SZ_4K]; + bool has_enclosing_repo = false; + if (!is_git_repo) { + if (!cbm_canonical_path(repo_path, canonical_repo, sizeof(canonical_repo))) { + snprintf(canonical_repo, sizeof(canonical_repo), "%s", repo_path); + } + cbm_normalize_path_sep(canonical_repo); + has_enclosing_repo = + resolve_enclosing_git_root(canonical_repo, enclosing_root, sizeof(enclosing_root), + git_common_dir, sizeof(git_common_dir)); + is_git_repo = has_enclosing_repo; } /* 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); - 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); - } - } + gitignore = cbm_gitignore_load(gi_path); 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); @@ -1299,7 +1432,16 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc char exc_path[CBM_SZ_4K]; 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 (has_enclosing_repo) { + /* git_common_dir belongs to the ENCLOSING repository, so its + * info/exclude is anchored at that repository's root, not at the + * indexed subfolder. Ownership passes to the ancestor chain. */ + if (!ancestor_ignores_build(&ancestors, enclosing_root, canonical_repo, git_exclude)) { + ancestor_ignores_free(&ancestors); + cbm_gitignore_free(gitignore); + return CBM_DISCOVER_ERROR; + } + } else if (git_exclude) { if (!gitignore) { gitignore = git_exclude; } else { @@ -1335,9 +1477,10 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc .collect_ignored = !count_only && ignored_out != NULL, }; walk_cache_dir_snapshot(); - walk_dir(repo_path, "", opts, gitignore, global_gi, cbmignore, &fl); + walk_dir(repo_path, "", opts, gitignore, ancestors.chain, global_gi, cbmignore, &fl); /* Cleanup */ + ancestor_ignores_free(&ancestors); cbm_gitignore_free(gitignore); cbm_gitignore_free(global_gi); cbm_gitignore_free(cbmignore); diff --git a/tests/test_discover.c b/tests/test_discover.c index ae4d9585d..d0ab9a020 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -1442,6 +1442,125 @@ TEST(discover_enclosing_repo_gitignore_local_overrides) { PASS(); } +/* An enclosing repo's patterns are ANCHORED to the enclosing repo's own root, + * never to the indexed subfolder. Folding them into the subfolder's matcher + * re-anchors every rooted pattern one or more levels too deep, in both + * directions. The four tests below pin the four cases; each expectation was + * taken from `git check-ignore` run inside the subfolder on an identical + * fixture, so they encode git's behaviour, not ours. + * + * Direction 1 — silent index loss, the worst failure mode for discovery: + * `/secret.py` at the enclosing root means "secret.py in the ROOT", so git + * indexes pkg/secret.py. Re-anchored onto pkg it becomes "secret.py in pkg" + * and the file vanishes from the index with no diagnostic. */ +TEST(discover_enclosing_rooted_pattern_not_reanchored) { + char *base = th_mktempdir("cbm_disc_enc_anchor"); + 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); + /* git check-ignore inside pkg: both files INDEXED. */ + ASSERT_EQ(count, 2); + ASSERT_TRUE(discover_has_rel_path(files, count, "secret.py")); + ASSERT_TRUE(discover_has_rel_path(files, count, "keep.py")); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + +/* Direction 2 — the mirror: a pattern the enclosing repo anchors THROUGH the + * indexed subfolder ("pkg/scratch/") no longer matches once rel_path is + * relative to pkg, so a directory git excludes gets walked and indexed. */ +TEST(discover_enclosing_info_exclude_rooted_subpath) { + char *base = th_mktempdir("cbm_disc_enc_exc_sub"); + ASSERT(base != NULL); + + th_mkdir_p(TH_PATH(base, ".git/info")); + th_write_file(TH_PATH(base, ".git/info/exclude"), "pkg/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); + /* git check-ignore inside pkg: scratch/tmp.py IGNORED, main.py indexed. */ + ASSERT_EQ(count, 1); + ASSERT_TRUE(discover_has_rel_path(files, count, "main.py")); + ASSERT_FALSE(discover_has_rel_path(files, count, "scratch/tmp.py")); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + +/* git consults the .gitignore of EVERY directory between the enclosing root + * and the indexed one, not just the root's. Here only /a/.gitignore has + * an opinion, and it is rooted at a/ — so it needs both the intermediate file + * to be loaded at all and its patterns to be matched relative to a/. */ +TEST(discover_enclosing_intermediate_gitignore) { + char *base = th_mktempdir("cbm_disc_enc_mid"); + ASSERT(base != NULL); + + th_mkdir_p(TH_PATH(base, ".git")); + th_write_file(TH_PATH(base, "a/.gitignore"), "/b/drop.py\n"); + th_write_file(TH_PATH(base, "a/b/drop.py"), "pass\n"); + th_write_file(TH_PATH(base, "a/b/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, "a/b"), &opts, &files, &count); + ASSERT_EQ(rc, 0); + /* git check-ignore inside a/b: drop.py IGNORED, keep.py indexed. */ + ASSERT_EQ(count, 1); + ASSERT_TRUE(discover_has_rel_path(files, count, "keep.py")); + ASSERT_FALSE(discover_has_rel_path(files, count, "drop.py")); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + +/* Precedence among ancestors: the deeper .gitignore wins over the shallower + * one, negations included. /.gitignore ignores every *.py; + * /a/.gitignore re-includes b/keep.py. Merging both into one matcher + * would decide this by file order instead of by depth. */ +TEST(discover_enclosing_deeper_ancestor_negation_wins) { + char *base = th_mktempdir("cbm_disc_enc_neg"); + 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, "a/.gitignore"), "!/b/keep.py\n"); + th_write_file(TH_PATH(base, "a/b/drop.py"), "pass\n"); + th_write_file(TH_PATH(base, "a/b/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, "a/b"), &opts, &files, &count); + ASSERT_EQ(rc, 0); + /* git check-ignore inside a/b: drop.py IGNORED, keep.py indexed. */ + ASSERT_EQ(count, 1); + ASSERT_TRUE(discover_has_rel_path(files, count, "keep.py")); + 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) { @@ -2062,6 +2181,10 @@ SUITE(discover) { RUN_TEST(discover_enclosing_repo_gitignore_issue510); RUN_TEST(discover_enclosing_repo_info_exclude); RUN_TEST(discover_enclosing_repo_gitignore_local_overrides); + RUN_TEST(discover_enclosing_rooted_pattern_not_reanchored); + RUN_TEST(discover_enclosing_info_exclude_rooted_subpath); + RUN_TEST(discover_enclosing_intermediate_gitignore); + RUN_TEST(discover_enclosing_deeper_ancestor_negation_wins); /* Nested .gitignore tests (issue #178) */ RUN_TEST(discover_nested_gitignore);