mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-07 08:43:35 +08: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:
co-authored by
Amir Fathi
parent
c8184ed948
commit
282fbfcdab
@@ -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