mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-06 17:49:47 +00:00
fix(discover): match an enclosing repository's ignore rules relative to their own directory
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 <amirfathi.me@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
c8184ed948
commit
282fbfcdab
2 files changed
+309
-43
No files matched your search
+186
-43
@@ -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
|
||||
* <subfolder>/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
|
||||
* <common>/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. <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
|
||||
* 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. 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);
|
||||
|
||||
@@ -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 <root>/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. <root>/.gitignore ignores every *.py;
|
||||
* <root>/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);
|
||||
|
||||
Reference in new issue
Block a user