mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-06 17:49:47 +00:00
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>
2199 lines
76 KiB
C
2199 lines
76 KiB
C
/*
|
|
* test_discover.c — Tests for directory skip logic, suffix filters, and file walk.
|
|
*
|
|
* RED phase: Tests define expected filtering behavior for the discover module.
|
|
*/
|
|
#include "test_framework.h"
|
|
#include "test_helpers.h"
|
|
#include "discover/discover.h"
|
|
#include "foundation/platform.h"
|
|
|
|
typedef struct {
|
|
char *home;
|
|
char *xdg_config_home;
|
|
} git_env_snapshot_t;
|
|
|
|
static git_env_snapshot_t save_git_env(void) {
|
|
git_env_snapshot_t snapshot = {0};
|
|
const char *home = getenv("HOME");
|
|
const char *xdg = getenv("XDG_CONFIG_HOME");
|
|
snapshot.home = home ? cbm_strdup(home) : NULL;
|
|
snapshot.xdg_config_home = xdg ? cbm_strdup(xdg) : NULL;
|
|
return snapshot;
|
|
}
|
|
|
|
static void restore_git_env(git_env_snapshot_t *snapshot) {
|
|
if (snapshot->home) {
|
|
cbm_setenv("HOME", snapshot->home, 1);
|
|
free(snapshot->home);
|
|
} else {
|
|
cbm_unsetenv("HOME");
|
|
}
|
|
|
|
if (snapshot->xdg_config_home) {
|
|
cbm_setenv("XDG_CONFIG_HOME", snapshot->xdg_config_home, 1);
|
|
free(snapshot->xdg_config_home);
|
|
} else {
|
|
cbm_unsetenv("XDG_CONFIG_HOME");
|
|
}
|
|
}
|
|
|
|
static bool discover_has_rel_path(const cbm_file_info_t *files, int count, const char *rel_path) {
|
|
for (int i = 0; i < count; i++) {
|
|
if (strcmp(files[i].rel_path, rel_path) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* ── Directory skip (always skipped) ───────────────────────────── */
|
|
|
|
TEST(skip_git) {
|
|
ASSERT_TRUE(cbm_should_skip_dir(".git", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_node_modules) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("node_modules", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_pycache) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("__pycache__", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_venv) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("venv", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_dist) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("dist", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_target) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("target", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_vendor) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("vendor", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_vendored) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("vendored", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_terraform) {
|
|
ASSERT_TRUE(cbm_should_skip_dir(".terraform", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_coverage) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("coverage", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_idea) {
|
|
ASSERT_TRUE(cbm_should_skip_dir(".idea", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(skip_claude) {
|
|
ASSERT_TRUE(cbm_should_skip_dir(".claude", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
|
|
/* Not skipped in full mode */
|
|
TEST(no_skip_src) {
|
|
ASSERT_FALSE(cbm_should_skip_dir("src", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(no_skip_lib) {
|
|
ASSERT_FALSE(cbm_should_skip_dir("lib", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(no_skip_docs_full) {
|
|
ASSERT_FALSE(cbm_should_skip_dir("docs", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(no_skip_test_full) {
|
|
ASSERT_FALSE(cbm_should_skip_dir("__tests__", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
|
|
/* Fast mode additional skips */
|
|
TEST(skip_fast_docs) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("docs", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_examples) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("examples", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_tests) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("__tests__", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_fixtures) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("fixtures", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_testdata) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("testdata", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_generated) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("generated", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_assets) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("assets", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_3rdparty) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("third_party", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(skip_fast_e2e) {
|
|
ASSERT_TRUE(cbm_should_skip_dir("e2e", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
|
|
/* ── Suffix filters ────────────────────────────────────────────── */
|
|
|
|
TEST(suffix_pyc) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("module.pyc", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_o) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("main.o", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_so) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("lib.so", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_png) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("icon.png", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_jpg) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("photo.jpg", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_wasm) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("app.wasm", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_db) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("data.db", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_sqlite) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("store.sqlite3", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_tmp) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("file.tmp", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_tilde) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("file~", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
|
|
/* Not ignored */
|
|
TEST(suffix_go) {
|
|
ASSERT_FALSE(cbm_has_ignored_suffix("main.go", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_py) {
|
|
ASSERT_FALSE(cbm_has_ignored_suffix("app.py", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
TEST(suffix_c) {
|
|
ASSERT_FALSE(cbm_has_ignored_suffix("lib.c", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
|
|
/* Fast mode additional suffixes */
|
|
TEST(suffix_fast_zip) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("archive.zip", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(suffix_fast_pdf) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("manual.pdf", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(suffix_fast_mp3) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("sound.mp3", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(suffix_fast_pem) {
|
|
ASSERT_TRUE(cbm_has_ignored_suffix("cert.pem", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
|
|
/* ── Filename skip (fast mode) ─────────────────────────────────── */
|
|
|
|
TEST(fn_skip_license) {
|
|
ASSERT_TRUE(cbm_should_skip_filename("LICENSE", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(fn_skip_changelog) {
|
|
ASSERT_TRUE(cbm_should_skip_filename("CHANGELOG.md", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(fn_skip_gosum) {
|
|
ASSERT_TRUE(cbm_should_skip_filename("go.sum", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(fn_skip_yarnlock) {
|
|
ASSERT_TRUE(cbm_should_skip_filename("yarn.lock", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(fn_skip_pkglock) {
|
|
ASSERT_TRUE(cbm_should_skip_filename("package-lock.json", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
|
|
/* Not skipped in full mode */
|
|
TEST(fn_no_skip_license_full) {
|
|
ASSERT_FALSE(cbm_should_skip_filename("LICENSE", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
|
|
/* ── Fast mode patterns ────────────────────────────────────────── */
|
|
|
|
TEST(pattern_dts) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("types.d.ts", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(pattern_pbgo) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("service.pb.go", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(pattern_pb2py) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("api_pb2.py", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(pattern_mock) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("mock_service.go", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(pattern_test_dot) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("App.test.js", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(pattern_spec) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("App.spec.ts", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
TEST(pattern_stories) {
|
|
ASSERT_TRUE(cbm_matches_fast_pattern("Button.stories.tsx", CBM_MODE_FAST));
|
|
PASS();
|
|
}
|
|
|
|
/* Not matched in full mode */
|
|
TEST(pattern_dts_full) {
|
|
ASSERT_FALSE(cbm_matches_fast_pattern("types.d.ts", CBM_MODE_FULL));
|
|
PASS();
|
|
}
|
|
|
|
/* ── File discovery (integration) — cross-platform via test_helpers.h ── */
|
|
|
|
/* A custom CBM_CACHE_DIR may legitimately sit inside a repository — tests do it
|
|
* routinely. Walking into it would pull every other project's graph database into
|
|
* this project's file list, so the walk prunes it by absolute path. */
|
|
TEST(discover_prunes_the_cache_tree) {
|
|
char *base = th_mktempdir("cbm_disc_cache");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
/* Source-looking files inside the cache must not be discovered. */
|
|
th_write_file(TH_PATH(base, "cache/other_project/leaked.go"), "package leaked\n");
|
|
|
|
const char *saved = getenv("CBM_CACHE_DIR");
|
|
char *saved_copy = saved ? strdup(saved) : NULL;
|
|
char cache_dir[1024];
|
|
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", base);
|
|
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
|
|
if (saved_copy) {
|
|
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
|
|
free(saved_copy);
|
|
} else {
|
|
cbm_unsetenv("CBM_CACHE_DIR");
|
|
}
|
|
|
|
ASSERT_EQ(rc, 0);
|
|
for (int i = 0; i < count; i++) {
|
|
ASSERT(strstr(files[i].rel_path, "leaked.go") == NULL);
|
|
}
|
|
ASSERT_EQ(count, 1); /* only src/main.go */
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_simple) {
|
|
char *base = th_mktempdir("cbm_disc_simple");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "src/app.py"), "print(1)\n");
|
|
th_write_file(TH_PATH(base, "src/icon.png"), "binary\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2); /* main.go + app.py, not icon.png */
|
|
|
|
bool found_go = false, found_py = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (files[i].language == CBM_LANG_GO)
|
|
found_go = true;
|
|
if (files[i].language == CBM_LANG_PYTHON)
|
|
found_py = true;
|
|
}
|
|
ASSERT_TRUE(found_go);
|
|
ASSERT_TRUE(found_py);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* A directory with more immediate subdirectories than the walk stack's
|
|
* initial capacity must still be fully discovered — dotnet/runtime's
|
|
* src/tests/JIT/Regression/JitBlue holds 855 sibling dirs and made the whole
|
|
* pipeline hard-fail with files=0 (walk_push_subdir treated the fixed cap as
|
|
* a hard error instead of growing). */
|
|
TEST(discover_wide_sibling_fanout_exceeds_initial_walk_stack) {
|
|
char *base = th_mktempdir("cbm_disc_wide");
|
|
ASSERT(base != NULL);
|
|
|
|
enum { WIDE_SIBLINGS = 600 };
|
|
for (int i = 0; i < WIDE_SIBLINGS; i++) {
|
|
char rel[64];
|
|
(void)snprintf(rel, sizeof(rel), "wide/d%03d/f.py", i);
|
|
th_write_file(TH_PATH(base, rel), "x = 1\n");
|
|
}
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, WIDE_SIBLINGS);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_bounded_count_is_allocation_free_and_limit_exact) {
|
|
char *base = th_mktempdir("cbm_disc_bounded");
|
|
ASSERT(base != NULL);
|
|
th_write_file(TH_PATH(base, "src/first.c"), "int first;\n");
|
|
th_write_file(TH_PATH(base, "src/second.py"), "second = 2\n");
|
|
th_write_file(TH_PATH(base, "src/ignored.png"), "not source\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
|
|
int limited_count = -1;
|
|
cbm_discover_status_t limited =
|
|
cbm_discover_count_bounded(base, &opts, 1, cbm_now_ms() + 2000, &limited_count);
|
|
int exact_count = -1;
|
|
cbm_discover_status_t exact =
|
|
cbm_discover_count_bounded(base, &opts, 2, cbm_now_ms() + 2000, &exact_count);
|
|
|
|
th_cleanup(base);
|
|
ASSERT_EQ(limited, CBM_DISCOVER_LIMIT_EXCEEDED);
|
|
ASSERT_EQ(limited_count, 1);
|
|
ASSERT_EQ(exact, CBM_DISCOVER_OK);
|
|
ASSERT_EQ(exact_count, 2);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_bounded_count_fails_closed_after_deadline) {
|
|
char *base = th_mktempdir("cbm_disc_deadline");
|
|
ASSERT(base != NULL);
|
|
th_write_file(TH_PATH(base, "source.c"), "int source;\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
|
|
int count = 99;
|
|
cbm_discover_status_t status = cbm_discover_count_bounded(base, &opts, 100, 1, &count);
|
|
|
|
th_cleanup(base);
|
|
ASSERT_EQ(status, CBM_DISCOVER_ERROR);
|
|
ASSERT_EQ(count, -1);
|
|
PASS();
|
|
}
|
|
|
|
/* The allocation-free bounded count must apply the same shebang fallback as
|
|
* full discovery (issue #1199): extensionless scripts count, extensionless
|
|
* plain text does not, and the limit boundary stays exact over that mix. */
|
|
TEST(discover_bounded_count_matches_shebang_discovery) {
|
|
char *base = th_mktempdir("cbm_disc_bounded_sb");
|
|
ASSERT(base != NULL);
|
|
th_write_file(TH_PATH(base, "keep.c"), "int keep;\n");
|
|
th_write_file(TH_PATH(base, "bin/build"), "#!/usr/bin/env python3\nprint(1)\n");
|
|
th_write_file(TH_PATH(base, "bin/deploy"), "#!/bin/bash\necho hi\n");
|
|
th_write_file(TH_PATH(base, "bin/notes"), "just some plain text\nno shebang here\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int full_rc = cbm_discover(base, &opts, &files, &count);
|
|
bool has_build = discover_has_rel_path(files, count, "bin/build");
|
|
bool has_deploy = discover_has_rel_path(files, count, "bin/deploy");
|
|
bool has_notes = discover_has_rel_path(files, count, "bin/notes");
|
|
cbm_discover_free(files, count);
|
|
|
|
int exact_count = -1;
|
|
cbm_discover_status_t exact =
|
|
cbm_discover_count_bounded(base, &opts, 3, cbm_now_ms() + 2000, &exact_count);
|
|
int limited_count = -1;
|
|
cbm_discover_status_t limited =
|
|
cbm_discover_count_bounded(base, &opts, 2, cbm_now_ms() + 2000, &limited_count);
|
|
|
|
th_cleanup(base);
|
|
|
|
ASSERT_EQ(full_rc, 0);
|
|
ASSERT_TRUE(has_build);
|
|
ASSERT_TRUE(has_deploy);
|
|
ASSERT_FALSE(has_notes);
|
|
ASSERT_EQ(count, 3);
|
|
ASSERT_EQ(exact, CBM_DISCOVER_OK);
|
|
ASSERT_EQ(exact_count, 3);
|
|
ASSERT_EQ(limited, CBM_DISCOVER_LIMIT_EXCEEDED);
|
|
ASSERT_EQ(limited_count, 2);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_skips_git_dir) {
|
|
char *base = th_mktempdir("cbm_disc_git");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".git/config"), "x\n");
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_with_gitignore) {
|
|
char *base = th_mktempdir("cbm_disc_gi");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "src/debug.log"), "error\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_EQ(files[0].language, CBM_LANG_GO);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_with_global_xdg_ignore) {
|
|
git_env_snapshot_t env = save_git_env();
|
|
char *tmp = th_mktempdir("cbm_disc_global_xdg");
|
|
ASSERT(tmp != NULL);
|
|
|
|
char base[512], repo[512], home[512], xdg[512], xdg_env[512];
|
|
snprintf(base, sizeof(base), "%s", tmp);
|
|
snprintf(repo, sizeof(repo), "%s/repo", base);
|
|
snprintf(home, sizeof(home), "%s/home", base);
|
|
snprintf(xdg, sizeof(xdg), "%s/xdg", base);
|
|
snprintf(xdg_env, sizeof(xdg_env), "%s/", xdg);
|
|
|
|
cbm_setenv("HOME", home, 1);
|
|
cbm_setenv("XDG_CONFIG_HOME", xdg_env, 1);
|
|
|
|
th_mkdir_p(TH_PATH(repo, ".git"));
|
|
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
|
|
th_write_file(TH_PATH(xdg, "git/ignore"), "secret.go\nignored_dir/\n");
|
|
th_write_file(TH_PATH(repo, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(repo, "secret.go"), "package secret\n");
|
|
th_write_file(TH_PATH(repo, "ignored_dir/thing.go"), "package ignored\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(repo, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "secret.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "ignored_dir/thing.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
restore_git_env(&env);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_global_excludesfile_from_gitconfig_tilde) {
|
|
git_env_snapshot_t env = save_git_env();
|
|
char *tmp = th_mktempdir("cbm_disc_global_cfg");
|
|
ASSERT(tmp != NULL);
|
|
|
|
char base[512], repo[512], home[512];
|
|
snprintf(base, sizeof(base), "%s", tmp);
|
|
snprintf(repo, sizeof(repo), "%s/repo", base);
|
|
snprintf(home, sizeof(home), "%s/home", base);
|
|
|
|
cbm_setenv("HOME", home, 1);
|
|
cbm_unsetenv("XDG_CONFIG_HOME");
|
|
|
|
th_mkdir_p(TH_PATH(repo, ".git"));
|
|
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
|
|
th_write_file(TH_PATH(home, ".gitconfig"), "[core]\n excludesFile = ~/custom-ignore\n");
|
|
th_write_file(TH_PATH(home, "custom-ignore"), "skip-me.go\n");
|
|
th_write_file(TH_PATH(repo, "keep.go"), "package keep\n");
|
|
th_write_file(TH_PATH(repo, "skip-me.go"), "package skip\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(repo, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "keep.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "skip-me.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
restore_git_env(&env);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_repo_local_excludesfile_is_ignored) {
|
|
git_env_snapshot_t env = save_git_env();
|
|
char *tmp = th_mktempdir("cbm_disc_repo_cfg_ignored");
|
|
ASSERT(tmp != NULL);
|
|
|
|
char base[512], repo[512], home[512], secret[512], config[1024];
|
|
snprintf(base, sizeof(base), "%s", tmp);
|
|
snprintf(repo, sizeof(repo), "%s/repo", base);
|
|
snprintf(home, sizeof(home), "%s/home", base);
|
|
snprintf(secret, sizeof(secret), "%s/secret-ignore", home);
|
|
snprintf(config, sizeof(config), "[core]\n excludesFile = %s\n", secret);
|
|
|
|
cbm_setenv("HOME", home, 1);
|
|
cbm_unsetenv("XDG_CONFIG_HOME");
|
|
|
|
th_mkdir_p(TH_PATH(repo, ".git"));
|
|
th_write_file(TH_PATH(repo, ".git/config"), config);
|
|
th_write_file(secret, "skip-me.go\n");
|
|
th_write_file(TH_PATH(repo, "keep.go"), "package keep\n");
|
|
th_write_file(TH_PATH(repo, "skip-me.go"), "package skip\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(repo, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "keep.go"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "skip-me.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
restore_git_env(&env);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_missing_global_excludes_is_noop) {
|
|
git_env_snapshot_t env = save_git_env();
|
|
char *tmp = th_mktempdir("cbm_disc_global_missing");
|
|
ASSERT(tmp != NULL);
|
|
|
|
char base[512], repo[512], home[512];
|
|
snprintf(base, sizeof(base), "%s", tmp);
|
|
snprintf(repo, sizeof(repo), "%s/repo", base);
|
|
snprintf(home, sizeof(home), "%s/home", base);
|
|
|
|
cbm_setenv("HOME", home, 1);
|
|
cbm_unsetenv("XDG_CONFIG_HOME");
|
|
|
|
th_mkdir_p(TH_PATH(repo, ".git"));
|
|
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
|
|
th_write_file(TH_PATH(repo, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(repo, "would-be-global.go"), "package global\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(repo, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "would-be-global.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
restore_git_env(&env);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_cbmignore_negates_global_ignore) {
|
|
git_env_snapshot_t env = save_git_env();
|
|
char *tmp = th_mktempdir("cbm_disc_global_neg");
|
|
ASSERT(tmp != NULL);
|
|
|
|
char base[512], repo[512], home[512], xdg[512];
|
|
snprintf(base, sizeof(base), "%s", tmp);
|
|
snprintf(repo, sizeof(repo), "%s/repo", base);
|
|
snprintf(home, sizeof(home), "%s/home", base);
|
|
snprintf(xdg, sizeof(xdg), "%s/xdg", base);
|
|
|
|
cbm_setenv("HOME", home, 1);
|
|
cbm_setenv("XDG_CONFIG_HOME", xdg, 1);
|
|
|
|
th_mkdir_p(TH_PATH(repo, ".git"));
|
|
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
|
|
th_write_file(TH_PATH(xdg, "git/ignore"), "rescued.go\nblocked.go\n");
|
|
th_write_file(TH_PATH(repo, ".cbmignore"), "!rescued.go\n");
|
|
th_write_file(TH_PATH(repo, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(repo, "rescued.go"), "package rescued\n");
|
|
th_write_file(TH_PATH(repo, "blocked.go"), "package blocked\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(repo, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "rescued.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "blocked.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
restore_git_env(&env);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* issue #234: a directory listed in the root .gitignore (e.g. "vendor/") must
|
|
* be excluded from discovery even when untracked — Composer/PHP projects rely
|
|
* on this. */
|
|
TEST(discover_gitignore_dir_excluded_issue234) {
|
|
char *base = th_mktempdir("cbm_disc_gi234");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "vendor/\n");
|
|
th_write_file(TH_PATH(base, "src/main.php"), "<?php\nfunction appmain() {}\n");
|
|
th_write_file(TH_PATH(base, "vendor/autoload.php"), "<?php\nfunction autoload() {}\n");
|
|
th_write_file(TH_PATH(base, "vendor/pkg/lib.php"), "<?php\nfunction lib() {}\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
/* Nothing under vendor/ should be discovered. */
|
|
for (int i = 0; i < count; i++) {
|
|
ASSERT_TRUE(strstr(files[i].rel_path, "vendor") == NULL);
|
|
}
|
|
ASSERT_EQ(count, 1); /* only src/main.php */
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_max_file_size) {
|
|
char *base = th_mktempdir("cbm_disc_size");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "small.go"), "small\n");
|
|
/* Create a large file (> 1KB) */
|
|
char bigpath[512];
|
|
snprintf(bigpath, sizeof(bigpath), "%s/big.go", base);
|
|
FILE *f = fopen(bigpath, "w");
|
|
ASSERT(f != NULL);
|
|
for (int i = 0; i < 200; i++) {
|
|
fprintf(f, "// padding line %d to exceed 1KB\n", i);
|
|
}
|
|
fclose(f);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
opts.max_file_size = 1024;
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_null_path) {
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(NULL, NULL, &files, &count);
|
|
ASSERT_EQ(rc, -1);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_nonexistent_path) {
|
|
char *base = th_mktempdir("cbm_disc_noexist");
|
|
char fake[512];
|
|
snprintf(fake, sizeof(fake), "%s/nonexistent_12345", base ? base : "/tmp");
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(fake, NULL, &files, &count);
|
|
ASSERT_EQ(rc, -1);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_free_null) {
|
|
cbm_discover_free(NULL, 0);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_skips_worktrees) {
|
|
char *base = th_mktempdir("cbm_disc_wt");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, ".worktrees/feature/src/app.go"), "package app\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
bool found_main = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strstr(files[i].rel_path, "main.go"))
|
|
found_main = true;
|
|
ASSERT_NULL(strstr(files[i].rel_path, ".worktrees"));
|
|
}
|
|
ASSERT_TRUE(found_main);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_cbmignore) {
|
|
char *base = th_mktempdir("cbm_disc_cbmi");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "generated/\n*.pb.go\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "generated/types.go"), "package gen\n");
|
|
th_write_file(TH_PATH(base, "api.pb.go"), "package api\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_cbmignore_stacks) {
|
|
char *base = th_mktempdir("cbm_disc_stack");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "docs/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "docs/api.go"), "package docs\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
bool found_docs = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strstr(files[i].rel_path, "docs/"))
|
|
found_docs = true;
|
|
}
|
|
ASSERT_FALSE(found_docs);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_symlink_skipped) {
|
|
#ifdef _WIN32
|
|
/* Symlinks require elevated privileges on Windows — skip.
|
|
* Guard the entire body: symlink() doesn't exist on Windows. */
|
|
SKIP_PLATFORM("Windows: symlinks need admin / symlink() unavailable");
|
|
#else
|
|
char *base = th_mktempdir("cbm_disc_sym");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "real.go"), "package main\n");
|
|
char real_path[512], link_path[512];
|
|
snprintf(real_path, sizeof(real_path), "%s/real.go", base);
|
|
snprintf(link_path, sizeof(link_path), "%s/link.go", base);
|
|
symlink(real_path, link_path);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
bool found_real = false, found_link = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strstr(files[i].rel_path, "real.go"))
|
|
found_real = true;
|
|
if (strstr(files[i].rel_path, "link.go"))
|
|
found_link = true;
|
|
}
|
|
ASSERT_TRUE(found_real);
|
|
ASSERT_FALSE(found_link);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
/* #1815: a symlink skip must be as visible as the other two skip paths in
|
|
* the same walk (gitignore/cbmignore/skip-list files, excluded dirs): a
|
|
* subtree dropped because it's a symlink must not look identical to a
|
|
* complete index. */
|
|
TEST(discover_symlink_skip_is_reported) {
|
|
#ifdef _WIN32
|
|
SKIP_PLATFORM("Windows: symlinks need admin / symlink() unavailable");
|
|
#else
|
|
char *base = th_mktempdir("cbm_disc_sym_rep");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "real.go"), "package main\n");
|
|
char real_path[512], link_path[512];
|
|
snprintf(real_path, sizeof(real_path), "%s/real.go", base);
|
|
snprintf(link_path, sizeof(link_path), "%s/link.go", base);
|
|
symlink(real_path, link_path);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
cbm_ignored_file_t *ignored = NULL;
|
|
int ignored_count = 0;
|
|
int ignored_total = 0;
|
|
|
|
int rc = cbm_discover_ex2(base, &opts, &files, &count, NULL, NULL, &ignored, &ignored_count,
|
|
&ignored_total);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1); /* real.go only, same as discover_symlink_skipped */
|
|
ASSERT_EQ(ignored_total, 1);
|
|
ASSERT_EQ(ignored_count, 1);
|
|
ASSERT_STR_EQ(ignored[0].rel_path, "link.go");
|
|
ASSERT_STR_EQ(ignored[0].reason, "symlink");
|
|
|
|
cbm_discover_free(files, count);
|
|
cbm_discover_free_ignored(ignored, ignored_count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
TEST(discover_new_ignore_patterns) {
|
|
char *base = th_mktempdir("cbm_disc_newign");
|
|
ASSERT(base != NULL);
|
|
|
|
const char *dirs[] = {".next", ".terraform", "zig-cache", ".cargo", "elm-stuff", "bazel-out"};
|
|
for (int i = 0; i < 6; i++) {
|
|
char path[512];
|
|
snprintf(path, sizeof(path), "%s/%s/file.go", base, dirs[i]);
|
|
th_write_file(path, "package x\n");
|
|
}
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_generic_dirs_full_mode) {
|
|
char *base = th_mktempdir("cbm_disc_genfull");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "bin/main.go"), "package bin\n");
|
|
th_write_file(TH_PATH(base, "build/main.go"), "package build\n");
|
|
th_write_file(TH_PATH(base, "out/main.go"), "package out\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 3);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_generic_dirs_fast_mode) {
|
|
char *base = th_mktempdir("cbm_disc_genfast");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "bin/main.go"), "package bin\n");
|
|
th_write_file(TH_PATH(base, "build/main.go"), "package build\n");
|
|
th_write_file(TH_PATH(base, "out/main.go"), "package out\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FAST};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 0);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_deploy_excluded_full_mode) {
|
|
char *base = th_mktempdir("cbm_disc_deploy");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "deploy/main.go"), "package deploy\n");
|
|
th_write_file(TH_PATH(base, "deployed/main.go"), "package deployed\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_cbmignore_no_git) {
|
|
char *base = th_mktempdir("cbm_disc_nogit");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "scratch/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "scratch/tmp.go"), "package scratch\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* ── .cbmignore negation vs built-in skip dirs (issue #500) ────── */
|
|
|
|
static bool discover_excluded_contains(char **excluded, int count, const char *rel_path) {
|
|
for (int i = 0; i < count; i++) {
|
|
if (strcmp(excluded[i], rel_path) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* A "!obj/" negation in .cbmignore must un-skip the built-in ALWAYS_SKIP
|
|
* "obj" dir so files inside it get discovered — and the un-skipped dir must
|
|
* not be reported as an excluded subtree (#411 list stays coherent). */
|
|
TEST(discover_cbmignore_negates_always_skip_dir) {
|
|
char *base = th_mktempdir("cbm_disc_cbmi_neg_obj");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "!obj/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "obj/generated.go"), "package obj\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
char **excluded = NULL;
|
|
int excluded_count = 0;
|
|
|
|
int rc = cbm_discover_ex(base, &opts, &files, &count, &excluded, &excluded_count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "obj/generated.go"));
|
|
ASSERT_FALSE(discover_excluded_contains(excluded, excluded_count, "obj"));
|
|
|
|
cbm_discover_free_excluded(excluded, excluded_count);
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* An anchored negation ("!src/target/") un-skips only that nested dir; other
|
|
* dirs with the same basename stay built-in-skipped. */
|
|
TEST(discover_cbmignore_negates_only_nested_skip_dir) {
|
|
char *base = th_mktempdir("cbm_disc_cbmi_neg_nested");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "!src/target/\n");
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package src\n");
|
|
th_write_file(TH_PATH(base, "src/target/lib.go"), "package target\n");
|
|
th_write_file(TH_PATH(base, "other/target/lib.go"), "package other\n");
|
|
th_write_file(TH_PATH(base, "target/root.go"), "package root\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "src/main.go"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "src/target/lib.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "other/target/lib.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "target/root.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* Negation also un-skips FAST-mode skip dirs ("docs" is in FAST_SKIP_DIRS). */
|
|
TEST(discover_cbmignore_negates_fast_skip_dir) {
|
|
char *base = th_mktempdir("cbm_disc_cbmi_neg_fast");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "!docs/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "docs/guide.go"), "package docs\n");
|
|
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FAST};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "docs/guide.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* Last-match-wins ordering: "obj/" then "!obj/" un-skips; "!obj/" then
|
|
* "obj/" re-ignores, so the built-in skip stands. */
|
|
TEST(discover_cbmignore_negation_last_match_wins) {
|
|
char *base = th_mktempdir("cbm_disc_cbmi_order1");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "obj/\n!obj/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "obj/generated.go"), "package obj\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "obj/generated.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
|
|
base = th_mktempdir("cbm_disc_cbmi_order2");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"), "!obj/\nobj/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "obj/generated.go"), "package obj\n");
|
|
|
|
files = NULL;
|
|
count = 0;
|
|
|
|
rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "obj/generated.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* Safety-core policy (#489, #802): .git, node_modules, and the
|
|
* worktree-internal dirs can never be un-skipped, even by an explicit
|
|
* .cbmignore negation. Green by construction; guards the policy. */
|
|
TEST(discover_cbmignore_negation_cannot_unskip_safety_core) {
|
|
char *base = th_mktempdir("cbm_disc_cbmi_safety");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".cbmignore"),
|
|
"!.git/\n!node_modules/\n!.worktrees/\n!.claude-worktrees/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, ".git/hooks/hook.go"), "package hooks\n");
|
|
th_write_file(TH_PATH(base, "node_modules/pkg/index.js"), "module.exports = 1;\n");
|
|
th_write_file(TH_PATH(base, ".worktrees/wt/dup.go"), "package dup\n");
|
|
th_write_file(TH_PATH(base, ".claude-worktrees/wt/dup.go"), "package dup\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, ".git/hooks/hook.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "node_modules/pkg/index.js"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, ".worktrees/wt/dup.go"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, ".claude-worktrees/wt/dup.go"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* ── .git/info/exclude tests (issue #489) ─────────────────────── */
|
|
|
|
/* Per-clone excludes written to .git/info/exclude (not committed) must be
|
|
* honored the same as .gitignore. Without this, repos that keep worktrees
|
|
* under a path excluded only via info/exclude hit OOM during indexing. */
|
|
TEST(discover_git_info_exclude) {
|
|
char *base = th_mktempdir("cbm_disc_exc");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git/info"));
|
|
th_write_file(TH_PATH(base, ".git/info/exclude"), "worktrees/\n");
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "worktrees/feature/app.go"), "package app\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_git_info_exclude_stacks_with_gitignore) {
|
|
char *base = th_mktempdir("cbm_disc_exc_stack");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git/info"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
|
|
th_write_file(TH_PATH(base, ".git/info/exclude"), "scratch/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "debug.log"), "log\n");
|
|
th_write_file(TH_PATH(base, "scratch/tmp.go"), "package scratch\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
|
|
bool found_log = false;
|
|
bool found_scratch = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strstr(files[i].rel_path, ".log"))
|
|
found_log = true;
|
|
if (strstr(files[i].rel_path, "scratch"))
|
|
found_scratch = true;
|
|
}
|
|
ASSERT_FALSE(found_log);
|
|
ASSERT_FALSE(found_scratch);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Linked-worktree ignore tests ──────────────────────────────── */
|
|
|
|
/* In a linked worktree, <repo>/.git is a regular file ("gitdir: <path>"), not a
|
|
* directory, and info/exclude + config live in the shared common dir named by
|
|
* <gitdir>/commondir. The discover step must follow the gitlink so per-clone
|
|
* excludes are honored exactly as in an ordinary checkout. Regression for the
|
|
* worktree gap left by issue #489 (which only handled .git as a directory). */
|
|
TEST(discover_worktree_info_exclude) {
|
|
char *base = th_mktempdir("cbm_disc_wt_exc");
|
|
ASSERT(base != NULL);
|
|
|
|
/* Worktree gitlink -> per-worktree gitdir (relative to the worktree root). */
|
|
th_write_file(TH_PATH(base, ".git"), "gitdir: maingit/worktrees/wt\n");
|
|
/* Per-worktree gitdir points back to the shared common dir via commondir. */
|
|
th_mkdir_p(TH_PATH(base, "maingit/worktrees/wt"));
|
|
th_write_file(TH_PATH(base, "maingit/worktrees/wt/commondir"), "../..\n");
|
|
/* Shared exclude lives in the common dir, not the per-worktree gitdir. */
|
|
th_mkdir_p(TH_PATH(base, "maingit/info"));
|
|
th_write_file(TH_PATH(base, "maingit/info/exclude"), "build/\n");
|
|
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "build/gen.go"), "package build\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* Committed .gitignore must still apply in a worktree even when commondir is
|
|
* absent (older gitdir layouts): the gitlink resolver falls back to the gitdir
|
|
* itself, and the root .gitignore is loaded unconditionally (issue #510). */
|
|
TEST(discover_worktree_committed_gitignore) {
|
|
char *base = th_mktempdir("cbm_disc_wt_gi");
|
|
ASSERT(base != NULL);
|
|
|
|
th_write_file(TH_PATH(base, ".git"), "gitdir: maingit/worktrees/wt\n");
|
|
th_mkdir_p(TH_PATH(base, "maingit/worktrees/wt"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "build/\n");
|
|
|
|
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "build/gen.go"), "package build\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
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();
|
|
}
|
|
|
|
/* 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) {
|
|
char *base = th_mktempdir("cbm_disc_ngi");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "webapp/.gitignore"), "generated/\n");
|
|
th_write_file(TH_PATH(base, "webapp/src/routes.js"), "export default []\n");
|
|
th_write_file(TH_PATH(base, "webapp/generated/types.js"), "export {}\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
|
|
bool found_generated = false;
|
|
bool found_routes = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strstr(files[i].rel_path, "generated"))
|
|
found_generated = true;
|
|
if (strstr(files[i].rel_path, "routes.js"))
|
|
found_routes = true;
|
|
}
|
|
ASSERT_FALSE(found_generated);
|
|
ASSERT_TRUE(found_routes);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
TEST(discover_nested_gitignore_stacks_with_root) {
|
|
char *base = th_mktempdir("cbm_disc_ngi_stack");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
|
|
th_write_file(TH_PATH(base, "webapp/.gitignore"), ".output/\n");
|
|
th_write_file(TH_PATH(base, "main.go"), "package main\n");
|
|
th_write_file(TH_PATH(base, "error.log"), "error log\n");
|
|
th_write_file(TH_PATH(base, "webapp/src/app.js"), "const x = 1\n");
|
|
th_write_file(TH_PATH(base, "webapp/.output/data.js"), "output data\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
|
|
bool found_log = false;
|
|
bool found_output = false;
|
|
bool found_main = false;
|
|
bool found_app = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strstr(files[i].rel_path, ".log"))
|
|
found_log = true;
|
|
if (strstr(files[i].rel_path, ".output"))
|
|
found_output = true;
|
|
if (strstr(files[i].rel_path, "main.go"))
|
|
found_main = true;
|
|
if (strstr(files[i].rel_path, "app.js"))
|
|
found_app = true;
|
|
}
|
|
ASSERT_FALSE(found_log);
|
|
ASSERT_FALSE(found_output);
|
|
ASSERT_TRUE(found_main);
|
|
ASSERT_TRUE(found_app);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* A repository may have one nested .gitignore per package. Ownership is
|
|
* cumulative across the walk, not bounded by the traversal depth, so more than
|
|
* 64 sibling matchers must remain valid for both full discovery and the
|
|
* allocation-free bounded count used by daemon auto-index admission. */
|
|
TEST(discover_many_nested_gitignores_do_not_exhaust_matcher_ownership) {
|
|
enum { PACKAGE_COUNT = 65 };
|
|
char *base = th_mktempdir("cbm_disc_many_ngi");
|
|
ASSERT(base != NULL);
|
|
|
|
bool fixture_ready = true;
|
|
for (int i = 0; i < PACKAGE_COUNT; i++) {
|
|
char ignore_path[1024];
|
|
char source_path[1024];
|
|
int ignore_written =
|
|
snprintf(ignore_path, sizeof(ignore_path), "%s/package-%02d/.gitignore", base, i);
|
|
int source_written =
|
|
snprintf(source_path, sizeof(source_path), "%s/package-%02d/source.go", base, i);
|
|
fixture_ready = fixture_ready && ignore_written > 0 &&
|
|
(size_t)ignore_written < sizeof(ignore_path) && source_written > 0 &&
|
|
(size_t)source_written < sizeof(source_path) &&
|
|
th_write_file(ignore_path, "ignored.tmp\n") == 0 &&
|
|
th_write_file(source_path, "package fixture\n") == 0;
|
|
}
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int discover_rc = fixture_ready ? cbm_discover(base, &opts, &files, &count) : -1;
|
|
int bounded_count = -1;
|
|
cbm_discover_status_t bounded_status =
|
|
fixture_ready
|
|
? cbm_discover_count_bounded(base, &opts, PACKAGE_COUNT + 1, 0, &bounded_count)
|
|
: CBM_DISCOVER_ERROR;
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
|
|
ASSERT_TRUE(fixture_ready);
|
|
ASSERT_EQ(discover_rc, 0);
|
|
ASSERT_EQ(count, PACKAGE_COUNT);
|
|
ASSERT_EQ(bounded_status, CBM_DISCOVER_OK);
|
|
ASSERT_EQ(bounded_count, PACKAGE_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Nested .gitignore BELOW another .gitignore (issue #1973) ─────── */
|
|
|
|
/* Laravel layout: storage/.gitignore exists (here: EMPTY) and
|
|
* storage/dump/.gitignore contains "*". git ignores every file under
|
|
* storage/dump/; the walk used to load only the SHALLOWEST nested .gitignore
|
|
* on a path (try_load_nested_gitignore() bailed once a frame carried a
|
|
* local matcher), so the deeper "*" was never consulted and thousands of
|
|
* ignored JSON dumps were discovered — the #1973 OOM kill. The bounded count
|
|
* (daemon auto-index admission) walks the same frames and must agree. */
|
|
TEST(discover_nested_gitignore_below_ancestor_gitignore_issue1973) {
|
|
enum { DUMP_FILES = 20 };
|
|
char *base = th_mktempdir("cbm_disc_ngi_1973");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "node_modules/\n");
|
|
th_write_file(TH_PATH(base, "storage/.gitignore"), "");
|
|
th_write_file(TH_PATH(base, "storage/dump/.gitignore"), "*\n");
|
|
th_write_file(TH_PATH(base, "app/x.php"), "<?php\nfunction appFn() { return 1; }\n");
|
|
th_write_file(TH_PATH(base, "storage/dump/nested/deep.json"), "{\"deep\": true}\n");
|
|
bool fixture_ready = true;
|
|
for (int i = 0; i < DUMP_FILES; i++) {
|
|
char rel[64];
|
|
snprintf(rel, sizeof(rel), "storage/dump/%05d.json", i);
|
|
fixture_ready = fixture_ready &&
|
|
th_write_file(TH_PATH(base, rel), "{\"route\": \"/api/v1/thing\"}\n") == 0;
|
|
}
|
|
ASSERT_TRUE(fixture_ready);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
for (int i = 0; i < count; i++) {
|
|
ASSERT(strstr(files[i].rel_path, "storage/dump/") == NULL);
|
|
}
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "app/x.php"));
|
|
ASSERT_EQ(count, 1);
|
|
cbm_discover_free(files, count);
|
|
|
|
int bounded_count = -1;
|
|
cbm_discover_status_t bounded_status =
|
|
cbm_discover_count_bounded(base, &opts, DUMP_FILES + 2, 0, &bounded_count);
|
|
ASSERT_EQ(bounded_status, CBM_DISCOVER_OK);
|
|
ASSERT_EQ(bounded_count, 1);
|
|
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* Precedence between .gitignore files on one path is git's: patterns in a
|
|
* deeper file override those in a shallower one, and every ancestor's
|
|
* patterns still apply to the subtree. A negation in the deepest file
|
|
* re-includes a file its ancestor ignores; siblings the deeper file does not
|
|
* mention stay ignored; a root pattern still reaches two levels down. */
|
|
TEST(discover_nested_gitignore_negation_overrides_ancestor_pattern) {
|
|
char *base = th_mktempdir("cbm_disc_ngi_neg");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
|
|
th_write_file(TH_PATH(base, "storage/.gitignore"), "*.json\n");
|
|
th_write_file(TH_PATH(base, "storage/dump/.gitignore"), "!keep.json\n");
|
|
th_write_file(TH_PATH(base, "app/x.php"), "<?php\nfunction appFn() { return 1; }\n");
|
|
th_write_file(TH_PATH(base, "storage/top.json"), "{\"top\": 1}\n");
|
|
th_write_file(TH_PATH(base, "storage/dump/keep.json"), "{\"keep\": 1}\n");
|
|
th_write_file(TH_PATH(base, "storage/dump/drop.json"), "{\"drop\": 1}\n");
|
|
th_write_file(TH_PATH(base, "storage/dump/run.log"), "log line\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "app/x.php"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "storage/dump/keep.json"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "storage/top.json"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "storage/dump/drop.json"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "storage/dump/run.log"));
|
|
ASSERT_EQ(count, 2);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* Three nesting levels: the deepest file wins for the paths it names, an
|
|
* intermediate negation reaches through an EMPTY .gitignore below it, and the
|
|
* root pattern still governs everything outside the re-included subtree. */
|
|
TEST(discover_nested_gitignore_three_levels_deeper_file_wins) {
|
|
char *base = th_mktempdir("cbm_disc_ngi_3lvl");
|
|
ASSERT(base != NULL);
|
|
|
|
th_mkdir_p(TH_PATH(base, ".git"));
|
|
th_write_file(TH_PATH(base, ".gitignore"), "*.json\n");
|
|
th_write_file(TH_PATH(base, "a/.gitignore"), "!*.json\n");
|
|
th_write_file(TH_PATH(base, "a/b/.gitignore"), "");
|
|
th_write_file(TH_PATH(base, "a/b/c/.gitignore"), "drop.json\n");
|
|
th_write_file(TH_PATH(base, "main.py"), "x = 1\n");
|
|
th_write_file(TH_PATH(base, "root.json"), "{\"root\": 1}\n");
|
|
th_write_file(TH_PATH(base, "a/x.json"), "{\"a\": 1}\n");
|
|
th_write_file(TH_PATH(base, "a/b/x.json"), "{\"b\": 1}\n");
|
|
th_write_file(TH_PATH(base, "a/b/c/keep.json"), "{\"keep\": 1}\n");
|
|
th_write_file(TH_PATH(base, "a/b/c/drop.json"), "{\"drop\": 1}\n");
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(base, &opts, &files, &count);
|
|
ASSERT_EQ(rc, 0);
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "main.py"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "a/x.json"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "a/b/x.json"));
|
|
ASSERT_TRUE(discover_has_rel_path(files, count, "a/b/c/keep.json"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "root.json"));
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "a/b/c/drop.json"));
|
|
ASSERT_EQ(count, 4);
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Shebang fallback for extensionless scripts (issue #1199) ────── */
|
|
|
|
/* Language detected for a discovered file by relative path, or CBM_LANG_COUNT
|
|
* if the file was not indexed. */
|
|
static CBMLanguage discover_lang_of(const cbm_file_info_t *files, int count, const char *rel) {
|
|
for (int i = 0; i < count; i++) {
|
|
if (strcmp(files[i].rel_path, rel) == 0) {
|
|
return files[i].language;
|
|
}
|
|
}
|
|
return CBM_LANG_COUNT;
|
|
}
|
|
|
|
/* Write raw bytes (may contain embedded NUL) to base/rel. Parent must exist. */
|
|
static int write_bytes_file(const char *path, const void *data, size_t n) {
|
|
FILE *f = cbm_fopen(path, "wb");
|
|
if (!f) {
|
|
return -1;
|
|
}
|
|
size_t w = fwrite(data, 1, n, f);
|
|
fclose(f);
|
|
return w == n ? 0 : -1;
|
|
}
|
|
|
|
/* Discover a single file with the given first-line content and report the
|
|
* language it was classified as via *out_lang (CBM_LANG_COUNT if unindexed).
|
|
* Returns true only when temp-dir creation, the file write, and discovery all
|
|
* succeeded, so callers can assert setup success before asserting the language
|
|
* -- otherwise a negative expectation (CBM_LANG_COUNT) could pass vacuously
|
|
* when setup silently failed. */
|
|
static bool shebang_probe(const char *prefix, const char *rel, const char *content,
|
|
CBMLanguage *out_lang) {
|
|
*out_lang = CBM_LANG_COUNT;
|
|
char *base = th_mktempdir(prefix);
|
|
if (!base) {
|
|
return false;
|
|
}
|
|
bool ok = false;
|
|
if (th_write_file(TH_PATH(base, rel), content) == 0) {
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
if (cbm_discover(base, &opts, &files, &count) == 0) {
|
|
*out_lang = discover_lang_of(files, count, rel);
|
|
ok = true;
|
|
}
|
|
cbm_discover_free(files, count);
|
|
}
|
|
th_cleanup(base);
|
|
return ok;
|
|
}
|
|
|
|
TEST(shebang_direct_python_path) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_dpy", "bin/run", "#!/usr/bin/python3\nprint(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_PYTHON);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_env_python) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_epy", "bin/run", "#!/usr/bin/env python3\nprint(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_PYTHON);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_env_split_string_python_args) {
|
|
CBMLanguage lang;
|
|
ASSERT(
|
|
shebang_probe("cbm_sb_spy", "bin/run", "#!/usr/bin/env -S python3 -u\nprint(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_PYTHON);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_versioned_python) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_vpy", "bin/run", "#!/usr/bin/python3.12\nprint(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_PYTHON);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_bash_direct) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_bash", "bin/run", "#!/bin/bash\necho hi\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_BASH);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_env_node_javascript) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_node", "bin/run", "#!/usr/bin/env node\nconsole.log(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_JAVASCRIPT);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_crlf_first_line) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_crlf", "bin/run", "#!/usr/bin/env python3\r\nprint(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_PYTHON);
|
|
PASS();
|
|
}
|
|
|
|
/* Recognized extension stays authoritative even with a conflicting shebang. */
|
|
TEST(shebang_extension_authoritative) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_ext", "weird.py", "#!/bin/bash\nprint(1)\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_PYTHON);
|
|
PASS();
|
|
}
|
|
|
|
/* Special filename stays authoritative even with a conflicting shebang. */
|
|
TEST(shebang_special_filename_authoritative) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_mk", "Makefile", "#!/usr/bin/env python3\nall:\n\techo\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_MAKEFILE);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_plain_text_unindexed) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_txt", "notes", "just some plain text\nno shebang here\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_malformed_unindexed) {
|
|
/* Missing '!' — not a shebang. */
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_mal", "notes", "#/usr/bin/python3\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_unknown_interpreter_unindexed) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_unk", "notes", "#!/usr/bin/frobnicate\ndata\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
/* Guard against arbitrary prefix matches (python-wrapper must NOT map). */
|
|
TEST(shebang_prefix_not_matched_unindexed) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_pw", "notes", "#!/usr/bin/python-wrapper\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
/* Numeric version components must be non-empty (reject trailing/doubled dots). */
|
|
TEST(shebang_malformed_python_version_unindexed) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_pdot", "notes", "#!/usr/bin/python3.\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
ASSERT(shebang_probe("cbm_sb_pddot", "notes", "#!/usr/bin/python3..12\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
/* env NAME=value assignment (with a slash in the value) must NOT be mistaken
|
|
* for an interpreter: env would set the var, not run "python" (issue #1199,
|
|
* Finding 2). */
|
|
TEST(shebang_env_assignment_not_matched_unindexed) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_asn", "notes",
|
|
"#!/usr/bin/env PYTHON=/usr/bin/python python-wrapper\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
/* Same shape behind -S must also stay unindexed. */
|
|
ASSERT(shebang_probe("cbm_sb_asn2", "notes",
|
|
"#!/usr/bin/env -S PYTHON=/usr/bin/python python\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
/* Unsupported env option forms (a leading '-' token that is not -S/
|
|
* --split-string) must stay unindexed rather than be treated as the
|
|
* interpreter (issue #1199, Finding 2). */
|
|
TEST(shebang_env_unsupported_option_unindexed) {
|
|
CBMLanguage lang;
|
|
ASSERT(shebang_probe("cbm_sb_eopt", "notes", "#!/usr/bin/env -i python3\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
ASSERT(shebang_probe("cbm_sb_eopt2", "notes", "#!/usr/bin/env -S -i python3\nx\n", &lang));
|
|
ASSERT_EQ(lang, CBM_LANG_COUNT);
|
|
PASS();
|
|
}
|
|
|
|
TEST(shebang_embedded_nul_unindexed) {
|
|
char *base = th_mktempdir("cbm_sb_nul");
|
|
ASSERT(base != NULL);
|
|
/* NUL embedded within the first line -> fail closed. */
|
|
static const char bytes[] = "#!/usr/bin/py\0thon3\nprint(1)\n";
|
|
ASSERT_EQ(write_bytes_file(TH_PATH(base, "run"), bytes, sizeof(bytes) - 1), 0);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_discover(base, &opts, &files, &count), 0);
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "run"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* A first line longer than the bounded read window (255 bytes) with no
|
|
* newline inside that window must fail closed even when it begins with an
|
|
* otherwise-valid Python shebang: the interpreter token is truncated, so the
|
|
* file must remain unindexed (issue #1199, Finding 1). */
|
|
TEST(shebang_oversized_first_line_unindexed) {
|
|
char *base = th_mktempdir("cbm_sb_long");
|
|
ASSERT(base != NULL);
|
|
|
|
/* "#!/usr/bin/python3 " + padding to push the newline past byte 255. */
|
|
char content[512];
|
|
int off = snprintf(content, sizeof(content), "#!/usr/bin/python3 ");
|
|
ASSERT(off > 0 && off < (int)sizeof(content));
|
|
for (int i = off; i < 400; i++) {
|
|
content[i] = 'a';
|
|
}
|
|
content[400] = '\n';
|
|
content[401] = '\0';
|
|
ASSERT_EQ(write_bytes_file(TH_PATH(base, "run"), content, 401), 0);
|
|
|
|
cbm_discover_opts_t opts = {0};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_discover(base, &opts, &files, &count), 0);
|
|
ASSERT_FALSE(discover_has_rel_path(files, count, "run"));
|
|
|
|
cbm_discover_free(files, count);
|
|
th_cleanup(base);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Suite ─────────────────────────────────────────────────────── */
|
|
|
|
SUITE(discover) {
|
|
RUN_TEST(discover_prunes_the_cache_tree);
|
|
/* Directory skip — always */
|
|
RUN_TEST(skip_git);
|
|
RUN_TEST(skip_node_modules);
|
|
RUN_TEST(skip_pycache);
|
|
RUN_TEST(skip_venv);
|
|
RUN_TEST(skip_dist);
|
|
RUN_TEST(skip_target);
|
|
RUN_TEST(skip_vendor);
|
|
RUN_TEST(skip_vendored);
|
|
RUN_TEST(skip_terraform);
|
|
RUN_TEST(skip_coverage);
|
|
RUN_TEST(skip_idea);
|
|
RUN_TEST(skip_claude);
|
|
|
|
/* Not skipped */
|
|
RUN_TEST(no_skip_src);
|
|
RUN_TEST(no_skip_lib);
|
|
RUN_TEST(no_skip_docs_full);
|
|
RUN_TEST(no_skip_test_full);
|
|
|
|
/* Fast mode directory skips */
|
|
RUN_TEST(skip_fast_docs);
|
|
RUN_TEST(skip_fast_examples);
|
|
RUN_TEST(skip_fast_tests);
|
|
RUN_TEST(skip_fast_fixtures);
|
|
RUN_TEST(skip_fast_testdata);
|
|
RUN_TEST(skip_fast_generated);
|
|
RUN_TEST(skip_fast_assets);
|
|
RUN_TEST(skip_fast_3rdparty);
|
|
RUN_TEST(skip_fast_e2e);
|
|
|
|
/* Suffix filters */
|
|
RUN_TEST(suffix_pyc);
|
|
RUN_TEST(suffix_o);
|
|
RUN_TEST(suffix_so);
|
|
RUN_TEST(suffix_png);
|
|
RUN_TEST(suffix_jpg);
|
|
RUN_TEST(suffix_wasm);
|
|
RUN_TEST(suffix_db);
|
|
RUN_TEST(suffix_sqlite);
|
|
RUN_TEST(suffix_tmp);
|
|
RUN_TEST(suffix_tilde);
|
|
RUN_TEST(suffix_go);
|
|
RUN_TEST(suffix_py);
|
|
RUN_TEST(suffix_c);
|
|
RUN_TEST(suffix_fast_zip);
|
|
RUN_TEST(suffix_fast_pdf);
|
|
RUN_TEST(suffix_fast_mp3);
|
|
RUN_TEST(suffix_fast_pem);
|
|
|
|
/* Filename skip */
|
|
RUN_TEST(fn_skip_license);
|
|
RUN_TEST(fn_skip_changelog);
|
|
RUN_TEST(fn_skip_gosum);
|
|
RUN_TEST(fn_skip_yarnlock);
|
|
RUN_TEST(fn_skip_pkglock);
|
|
RUN_TEST(fn_no_skip_license_full);
|
|
|
|
/* Fast mode patterns */
|
|
RUN_TEST(pattern_dts);
|
|
RUN_TEST(pattern_pbgo);
|
|
RUN_TEST(pattern_pb2py);
|
|
RUN_TEST(pattern_mock);
|
|
RUN_TEST(pattern_test_dot);
|
|
RUN_TEST(pattern_spec);
|
|
RUN_TEST(pattern_stories);
|
|
RUN_TEST(pattern_dts_full);
|
|
|
|
/* Shebang fallback for extensionless scripts (issue #1199) */
|
|
RUN_TEST(shebang_direct_python_path);
|
|
RUN_TEST(shebang_env_python);
|
|
RUN_TEST(shebang_env_split_string_python_args);
|
|
RUN_TEST(shebang_versioned_python);
|
|
RUN_TEST(shebang_bash_direct);
|
|
RUN_TEST(shebang_env_node_javascript);
|
|
RUN_TEST(shebang_crlf_first_line);
|
|
RUN_TEST(shebang_extension_authoritative);
|
|
RUN_TEST(shebang_special_filename_authoritative);
|
|
RUN_TEST(shebang_plain_text_unindexed);
|
|
RUN_TEST(shebang_malformed_unindexed);
|
|
RUN_TEST(shebang_unknown_interpreter_unindexed);
|
|
RUN_TEST(shebang_prefix_not_matched_unindexed);
|
|
RUN_TEST(shebang_malformed_python_version_unindexed);
|
|
RUN_TEST(shebang_env_assignment_not_matched_unindexed);
|
|
RUN_TEST(shebang_env_unsupported_option_unindexed);
|
|
RUN_TEST(shebang_embedded_nul_unindexed);
|
|
RUN_TEST(shebang_oversized_first_line_unindexed);
|
|
|
|
/* Integration tests (cross-platform) */
|
|
RUN_TEST(discover_simple);
|
|
RUN_TEST(discover_wide_sibling_fanout_exceeds_initial_walk_stack);
|
|
RUN_TEST(discover_bounded_count_is_allocation_free_and_limit_exact);
|
|
RUN_TEST(discover_bounded_count_fails_closed_after_deadline);
|
|
RUN_TEST(discover_bounded_count_matches_shebang_discovery);
|
|
RUN_TEST(discover_skips_git_dir);
|
|
RUN_TEST(discover_with_gitignore);
|
|
RUN_TEST(discover_with_global_xdg_ignore);
|
|
RUN_TEST(discover_global_excludesfile_from_gitconfig_tilde);
|
|
RUN_TEST(discover_repo_local_excludesfile_is_ignored);
|
|
RUN_TEST(discover_missing_global_excludes_is_noop);
|
|
RUN_TEST(discover_cbmignore_negates_global_ignore);
|
|
RUN_TEST(discover_gitignore_dir_excluded_issue234);
|
|
RUN_TEST(discover_max_file_size);
|
|
RUN_TEST(discover_null_path);
|
|
RUN_TEST(discover_nonexistent_path);
|
|
RUN_TEST(discover_free_null);
|
|
|
|
/* Go test ports (cross-platform) */
|
|
RUN_TEST(discover_skips_worktrees);
|
|
RUN_TEST(discover_cbmignore);
|
|
RUN_TEST(discover_cbmignore_stacks);
|
|
RUN_TEST(discover_symlink_skipped);
|
|
RUN_TEST(discover_symlink_skip_is_reported);
|
|
RUN_TEST(discover_new_ignore_patterns);
|
|
RUN_TEST(discover_generic_dirs_full_mode);
|
|
RUN_TEST(discover_generic_dirs_fast_mode);
|
|
RUN_TEST(discover_deploy_excluded_full_mode);
|
|
RUN_TEST(discover_cbmignore_no_git);
|
|
|
|
/* .cbmignore negation vs built-in skip dirs (issue #500) */
|
|
RUN_TEST(discover_cbmignore_negates_always_skip_dir);
|
|
RUN_TEST(discover_cbmignore_negates_only_nested_skip_dir);
|
|
RUN_TEST(discover_cbmignore_negates_fast_skip_dir);
|
|
RUN_TEST(discover_cbmignore_negation_last_match_wins);
|
|
RUN_TEST(discover_cbmignore_negation_cannot_unskip_safety_core);
|
|
|
|
/* .git/info/exclude support (issue #489) */
|
|
RUN_TEST(discover_git_info_exclude);
|
|
RUN_TEST(discover_git_info_exclude_stacks_with_gitignore);
|
|
|
|
/* Linked-worktree ignore resolution (gitlink + commondir) */
|
|
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);
|
|
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);
|
|
RUN_TEST(discover_nested_gitignore_stacks_with_root);
|
|
RUN_TEST(discover_many_nested_gitignores_do_not_exhaust_matcher_ownership);
|
|
|
|
/* Nested .gitignore below another .gitignore (issue #1973) */
|
|
RUN_TEST(discover_nested_gitignore_below_ancestor_gitignore_issue1973);
|
|
RUN_TEST(discover_nested_gitignore_negation_overrides_ancestor_pattern);
|
|
RUN_TEST(discover_nested_gitignore_three_levels_deeper_file_wins);
|
|
}
|