Files
codebase-memory-mcp/src/discover/discover.c
T
Martin Vogel 867e5f883d Merge origin/main into the smart-CI branch
Ten files changed on both sides; every conflict was a union of independent
additions:

- workflows (_lint.yml, _test.yml): keep the branch's lane `if:` and main's
  pinned ubuntu-24.04 runner with its reason.
- internal/cbm/cbm.h: main's callable-identity and module-doc fields plus the
  branch's configured-test fields.
- Makefile.cbm: both new test files; prod_lsp_all.o keeps main's
  .build-config dependency and the branch's coverage flags.
- scripts/test.sh: main's setup-scripts contract becomes Step 0y3, ahead of
  the branch's 0z series and the contracts-only exit.
- src/foundation/subprocess.c: main's cbm_posix_close_fds_from (it closes the
  error descriptor too).
- src/mcp/mcp.c: main's repository default base for detect_changes, with the
  early returns released through the branch's detect_release.
- src/pipeline/lsp_resolve.h, tests/test_main.c: both includes, suites and
  start-up steps (main's git environment reset first).
- scripts/memory-core-baseline.txt: recounted on the merged tree and lowered
  (mcp.c 773, config_toml_edit.c 73).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-05 18:53:37 +02:00

1628 lines
62 KiB
C

/*
* discover.c — Recursive directory walk with filtering.
*
* Walks a repository directory tree, applying:
* 1. Hardcoded directory skip patterns (60+ dirs like .git, node_modules,
* plus path-suffix rules such as Laravel's storage/framework/views)
* 2. Hardcoded suffix filters (.pyc, .png, .wasm, etc.)
* 3. Fast-mode additional filters (docs, examples, lock files, etc.)
* 4. Gitignore-style pattern matching
* 5. Language detection for accepted files
*/
#include "discover/discover.h"
#include "discover/discover_policy_internal.h"
#include "cbm.h" // CBMLanguage, CBM_LANG_COUNT, CBM_LANG_JSON
#include "foundation/constants.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "foundation/workspace.h"
#include "foundation/platform.h"
#ifdef _WIN32
#include "foundation/win_utf8.h"
#endif
#include <ctype.h>
#include <stdint.h> // int64_t
#include <stdio.h>
#include <stdlib.h>
#include <string.h> // strdup
#include <sys/stat.h>
int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir);
/* ── Hardcoded always-skip directories ──────────────────────────── */
static const char *ALWAYS_SKIP_DIRS[] = {
/* VCS */
".git", ".hg", ".svn", ".worktrees",
/* IDE */
".idea", ".vs", ".vscode", ".eclipse", ".claude", ".claude-worktrees", "Antigravity",
/* Python */
".cache", ".eggs", ".env", ".mypy_cache", ".nox", ".pytest_cache", ".ruff_cache", ".tox",
".venv", "__pycache__", "env", "htmlcov", "site-packages", "venv",
/* JS/TS */
".npm", ".nyc_output", ".pnpm-store", ".yarn", "bower_components", "coverage", "node_modules",
".next", ".nuxt", ".svelte-kit", ".angular", ".turbo", ".parcel-cache", ".docusaurus", ".expo",
/* Build artifacts */
"dist", "obj", "Pods", "target", "temp", "tmp", ".terraform", ".serverless", "bazel-bin",
"bazel-out", "bazel-testlogs",
/* Language caches */
".cargo", ".stack-work", ".dart_tool", "zig-cache", "zig-out", ".metals", ".bloop", ".bsp",
".ccls-cache", ".clangd", "elm-stuff", "_opam", ".cpcache", ".shadow-cljs",
/* Deploy */
".vercel", ".netlify", "deploy", "deployed",
/* Misc */
".codebase-memory", ".qdrant_code_embeddings", ".tmp", "vendor", "vendored", NULL};
/* Always-skip directories matched by relative-PATH suffix, not by basename:
* generated build output whose last path component alone ("views") is far too
* common to skip everywhere. A rule matches the whole relative path or a
* '/'-aligned tail of it, so "storage/framework/views" and
* "backend/storage/framework/views" match while "app/views",
* "mystorage/framework/views" and "storage/framework/views_old" do not. */
static const char *ALWAYS_SKIP_DIR_SUFFIXES[] = {
/* Laravel's compiled Blade view cache (#1735): generated PHP with no source
* value, skipped even when Laravel's stock .gitignore there is missing. */
"storage/framework/views", NULL};
static const char *FAST_SKIP_DIRS[] = {
"generated", "gen", "auto-generated", "fixtures", "testdata", "test_data",
"__tests__", "__mocks__", "__snapshots__", "__fixtures__", "__test__", "docs",
"doc", "documentation", "examples", "example", "samples", "sample",
"assets", "static", "public", "media", "third_party", "thirdparty",
"3rdparty", "external", "migrations", "seeds", "e2e", "integration",
"locale", "locales", "i18n", "l10n", "scripts", "tools",
"hack", "bin", "build", "out", NULL};
/* ── Ignored suffixes ───────────────────────────────── */
static const char *ALWAYS_IGNORED_SUFFIXES[] = {
".tmp", "~", ".pyc", ".pyo", ".o", ".a", ".so", ".dll",
".class", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".bmp", ".tiff",
".webp", ".svg", ".wasm", ".node", ".exe", ".bin", ".dat", ".db",
".sqlite", ".sqlite3", ".woff", ".woff2", ".ttf", ".eot", ".otf", NULL};
static const char *FAST_IGNORED_SUFFIXES[] = {
".zip", ".tar", ".gz", ".bz2", ".xz", ".rar", ".7z", ".jar",
".war", ".ear", ".mp3", ".mp4", ".avi", ".mov", ".wav", ".flac",
".ogg", ".mkv", ".webm", ".pdf", ".doc", ".docx", ".xls", ".xlsx",
".ppt", ".pptx", ".odt", ".ods", ".map", ".min.js", ".min.css", ".pem",
".crt", ".key", ".cer", ".p12", ".pb", ".avro", ".parquet", ".beam",
".elc", ".rlib", ".coverage", ".prof", ".out", ".patch", ".diff", NULL};
/* ── Fast-mode skip filenames ─────────────────────── */
static const char *FAST_SKIP_FILENAMES[] = {
"LICENSE", "LICENSE.txt", "LICENSE.md", "LICENSE-MIT", "LICENSE-APACHE",
"LICENCE", "LICENCE.txt", "LICENCE.md", "CHANGELOG", "CHANGELOG.md",
"CHANGES.md", "HISTORY", "HISTORY.md", "AUTHORS", "AUTHORS.md",
"CONTRIBUTORS", "CONTRIBUTORS.md", "CODEOWNERS", "go.sum", "yarn.lock",
"pnpm-lock.yaml", "Pipfile.lock", "poetry.lock", "Gemfile.lock", "Cargo.lock",
"mix.lock", "flake.lock", "pubspec.lock", "composer.lock", "package-lock.json",
"configure", "Makefile.in", "config.guess", "config.sub", NULL};
/* ── Fast-mode substring patterns ───────────────────── */
static const char *FAST_PATTERNS[] = {".d.ts", ".bundle.", ".chunk.", ".generated.",
".pb.go", "_pb2.py", ".pb2.py", "_grpc.pb.go",
"_string.go", "mock_", "_mock.", "_test_helpers.",
".stories.", ".spec.", ".test.", NULL};
/* ── Helper: check if string is in NULL-terminated array ─────────── */
static bool str_in_list(const char *s, const char *const *list) {
for (int i = 0; list[i]; i++) {
if (strcmp(s, list[i]) == 0) {
return true;
}
}
return false;
}
/* ── Helper: check if string ends with suffix ────────────── */
static bool ends_with(const char *s, size_t slen, const char *suffix) {
size_t sufflen = strlen(suffix);
if (sufflen > slen) {
return false;
}
return strcmp(s + slen - sufflen, suffix) == 0;
}
/* ── Helper: check if string contains substring ───────────── */
static bool str_contains(const char *s, const char *sub) {
return strstr(s, sub) != NULL;
}
/* ── Git global excludes resolution ───────────────────────────── */
enum { GIT_TILDE_PREFIX_LEN = 2 }; /* "~/". */
static bool ascii_ieq(const char *a, const char *b) {
while (*a && *b) {
if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) {
return false;
}
a++;
b++;
}
return *a == '\0' && *b == '\0';
}
static char *trim_ws(char *s) {
while (*s && isspace((unsigned char)*s)) {
s++;
}
char *end = s + strlen(s);
while (end > s && isspace((unsigned char)end[-1])) {
end--;
}
*end = '\0';
return s;
}
static void strip_inline_comment(char *s) {
bool in_quote = false;
char quote = '\0';
for (char *p = s; *p; p++) {
if ((*p == '"' || *p == '\'') && (p == s || p[-1] != '\\')) {
if (!in_quote) {
in_quote = true;
quote = *p;
} else if (*p == quote) {
in_quote = false;
}
continue;
}
if (!in_quote && (*p == '#' || *p == ';') && (p == s || isspace((unsigned char)p[-1]))) {
*p = '\0';
return;
}
}
}
static char *strip_matching_quotes(char *s) {
size_t len = strlen(s);
if (len >= CBM_QUOTE_PAIR && ((s[0] == '"' && s[len - SKIP_ONE] == '"') ||
(s[0] == '\'' && s[len - SKIP_ONE] == '\''))) {
s[len - SKIP_ONE] = '\0';
return s + SKIP_ONE;
}
return s;
}
static bool has_trailing_sep(const char *path) {
size_t len = strlen(path);
return len > 0 && (path[len - SKIP_ONE] == '/' || path[len - SKIP_ONE] == '\\');
}
static void path_join(char *out, size_t out_sz, const char *base, const char *rel) {
if (!out || out_sz == 0) {
return;
}
if (!base || base[0] == '\0') {
snprintf(out, out_sz, "%s", rel ? rel : "");
} else if (!rel || rel[0] == '\0') {
snprintf(out, out_sz, "%s", base);
} else if (has_trailing_sep(base)) {
snprintf(out, out_sz, "%s%s", base, rel);
} else {
snprintf(out, out_sz, "%s/%s", base, rel);
}
cbm_normalize_path_sep(out);
}
static bool expand_git_path(const char *path, char *out, size_t out_sz) {
if (!path || !path[0] || !out || out_sz == 0) {
return false;
}
char normalized[CBM_SZ_4K];
snprintf(normalized, sizeof(normalized), "%s", path);
cbm_normalize_path_sep(normalized);
if (normalized[0] != '~') {
snprintf(out, out_sz, "%s", normalized);
cbm_normalize_path_sep(out);
return out[0] != '\0';
}
if (normalized[1] != '\0' && normalized[1] != '/') {
return false; /* ~user expansion is intentionally not supported. */
}
const char *home = cbm_get_home_dir();
if (!home || home[0] == '\0') {
return false;
}
if (normalized[1] == '\0') {
snprintf(out, out_sz, "%s", home);
cbm_normalize_path_sep(out);
} else {
path_join(out, out_sz, home, normalized + GIT_TILDE_PREFIX_LEN);
}
return out[0] != '\0';
}
static bool read_core_excludes_file(const char *config_path, char *out, size_t out_sz) {
FILE *f = cbm_fopen(config_path, "r");
if (!f) {
return false;
}
bool in_core = false;
bool found = false;
char line[CBM_SZ_4K];
while (fgets(line, sizeof(line), f)) {
char *s = trim_ws(line);
if (s[0] == '\0' || s[0] == '#' || s[0] == ';') {
continue;
}
if (s[0] == '[') {
char *end = strchr(s, ']');
if (!end) {
in_core = false;
continue;
}
*end = '\0';
in_core = ascii_ieq(trim_ws(s + SKIP_ONE), "core");
continue;
}
if (!in_core) {
continue;
}
char *eq = strchr(s, '=');
if (!eq) {
continue;
}
*eq = '\0';
char *key = trim_ws(s);
char *value = trim_ws(eq + SKIP_ONE);
strip_inline_comment(value);
value = strip_matching_quotes(trim_ws(value));
if (ascii_ieq(key, "excludesfile") && value[0] != '\0' &&
expand_git_path(value, out, out_sz)) {
found = true;
}
}
fclose(f);
return found;
}
static bool resolve_xdg_git_config_dir(char *out, size_t out_sz) {
char env[CBM_SZ_4K];
if (cbm_safe_getenv("XDG_CONFIG_HOME", env, sizeof(env), NULL) && env[0] != '\0') {
snprintf(out, out_sz, "%s", env);
cbm_normalize_path_sep(out);
return true;
}
const char *home = cbm_get_home_dir();
if (!home || home[0] == '\0') {
return false;
}
path_join(out, out_sz, home, ".config");
return out[0] != '\0';
}
static bool resolve_global_excludes_path(char *out, size_t out_sz) {
char config_path[CBM_SZ_4K];
const char *home = cbm_get_home_dir();
if (home && home[0] != '\0') {
path_join(config_path, sizeof(config_path), home, ".gitconfig");
if (read_core_excludes_file(config_path, out, out_sz)) {
return true;
}
}
char xdg_config[CBM_SZ_4K];
if (resolve_xdg_git_config_dir(xdg_config, sizeof(xdg_config))) {
path_join(config_path, sizeof(config_path), xdg_config, "git/config");
if (read_core_excludes_file(config_path, out, out_sz)) {
return true;
}
path_join(out, out_sz, xdg_config, "git/ignore");
return out[0] != '\0';
}
return false;
}
/* True when rel_path equals a suffix rule or ends with "/" + the rule. */
bool cbm_discover_path_has_skip_suffix(const char *rel_path) {
if (!rel_path) {
return false;
}
size_t plen = strlen(rel_path);
for (int i = 0; ALWAYS_SKIP_DIR_SUFFIXES[i]; i++) {
const char *suffix = ALWAYS_SKIP_DIR_SUFFIXES[i];
size_t slen = strlen(suffix);
if (slen > plen || strcmp(rel_path + plen - slen, suffix) != 0) {
continue;
}
if (slen == plen || rel_path[plen - slen - SKIP_ONE] == '/') {
return true;
}
}
return false;
}
/* ── Public filter functions ─────────────────────── */
bool cbm_should_skip_dir(const char *dirname, cbm_index_mode_t mode) {
if (!dirname) {
return false;
}
if (str_in_list(dirname, ALWAYS_SKIP_DIRS)) {
return true;
}
/* Fast discovery applies to both MODERATE and FAST — only FULL keeps everything. */
if (mode != CBM_MODE_FULL) {
if (str_in_list(dirname, FAST_SKIP_DIRS)) {
return true;
}
}
return false;
}
bool cbm_has_ignored_suffix(const char *filename, cbm_index_mode_t mode) {
if (!filename) {
return false;
}
size_t filename_length = strlen(filename);
for (int i = 0; ALWAYS_IGNORED_SUFFIXES[i]; i++) {
if (ends_with(filename, filename_length, ALWAYS_IGNORED_SUFFIXES[i])) {
return true;
}
}
if (mode != CBM_MODE_FULL) {
for (int i = 0; FAST_IGNORED_SUFFIXES[i]; i++) {
if (ends_with(filename, filename_length, FAST_IGNORED_SUFFIXES[i])) {
return true;
}
}
}
return false;
}
bool cbm_should_skip_filename(const char *filename, cbm_index_mode_t mode) {
if (!filename) {
return false;
}
if (mode != CBM_MODE_FULL) {
if (str_in_list(filename, FAST_SKIP_FILENAMES)) {
return true;
}
}
return false;
}
bool cbm_matches_fast_pattern(const char *filename, cbm_index_mode_t mode) {
if (!filename || mode == CBM_MODE_FULL) {
return false;
}
for (int i = 0; FAST_PATTERNS[i]; i++) {
if (str_contains(filename, FAST_PATTERNS[i])) {
return true;
}
}
return false;
}
/* ── Dynamic file list ────────────────────────── */
typedef struct {
cbm_file_info_t *files;
int count;
int capacity;
int max_files;
uint64_t deadline_ms;
const cbm_index_resource_policy_t *resource_policy;
cbm_index_resource_violation_t *resource_violation;
uint64_t source_files;
uint64_t source_bytes;
bool count_only;
bool collect_excluded;
bool limit_exceeded;
bool failed;
/* Directories skipped during the walk (rel paths), so callers can surface
* which subtrees were dropped (#411). strdup'd; freed by the caller via
* cbm_discover_free_excluded or internally when not requested. */
char **excluded;
int excluded_count;
int excluded_cap;
/* Individual files dropped by ignore rules (#963 "purposely not
* indexed"). Collected only when requested (collect_ignored); stored
* entries are capped at CBM_DISCOVER_IGNORED_CAP while ignored_total
* keeps counting so truncation is explicit. */
bool collect_ignored;
cbm_ignored_file_t *ignored;
int ignored_count;
int ignored_cap;
int ignored_total;
} file_list_t;
static void file_list_resource_violation(file_list_t *fl, cbm_index_resource_t resource,
uint64_t observed, uint64_t limit) {
if (!fl || fl->limit_exceeded || fl->failed) {
return;
}
fl->limit_exceeded = true;
if (fl->resource_violation) {
*fl->resource_violation = (cbm_index_resource_violation_t){
.resource = resource,
.observed = observed,
.limit = limit,
};
}
}
static bool file_list_should_stop(file_list_t *fl) {
if (!fl) {
return true;
}
if (!fl->failed && fl->deadline_ms != 0 && cbm_now_ms() >= fl->deadline_ms) {
fl->failed = true;
}
return fl->failed || fl->limit_exceeded;
}
static void file_list_add_excluded(file_list_t *fl, const char *rel_path) {
if (!fl->collect_excluded || !rel_path || rel_path[0] == '\0') {
return;
}
if (fl->excluded_count >= fl->excluded_cap) {
int new_cap = fl->excluded_cap ? fl->excluded_cap * PAIR_LEN : CBM_SZ_64;
char **grown = realloc(fl->excluded, new_cap * sizeof(char *));
if (!grown) {
fl->failed = true;
return;
}
fl->excluded = grown;
fl->excluded_cap = new_cap;
}
char *copy = strdup(rel_path);
if (!copy) {
fl->failed = true;
return;
}
fl->excluded[fl->excluded_count++] = copy;
}
static void file_list_add_ignored(file_list_t *fl, const char *rel_path, const char *reason) {
if (!fl->collect_ignored || !rel_path || rel_path[0] == '\0') {
return;
}
fl->ignored_total++;
if (fl->ignored_count >= CBM_DISCOVER_IGNORED_CAP) {
return; /* counted above — truncation stays visible via the total */
}
if (fl->ignored_count >= fl->ignored_cap) {
int new_cap = fl->ignored_cap ? fl->ignored_cap * PAIR_LEN : CBM_SZ_64;
cbm_ignored_file_t *grown = realloc(fl->ignored, new_cap * sizeof(cbm_ignored_file_t));
if (!grown) {
fl->failed = true;
return;
}
fl->ignored = grown;
fl->ignored_cap = new_cap;
}
char *path_copy = strdup(rel_path);
char *reason_copy = strdup(reason ? reason : "");
if (!path_copy || !reason_copy) {
free(path_copy);
free(reason_copy);
fl->failed = true;
return;
}
fl->ignored[fl->ignored_count].rel_path = path_copy;
fl->ignored[fl->ignored_count].reason = reason_copy;
fl->ignored_count++;
}
/* Nanosecond mtime from a stat result, per platform. Windows' wide_stat only
* carries whole seconds, so the value is seconds scaled to ns there. */
#define DISCOVER_NS_PER_SEC 1000000000LL
static int64_t discover_stat_mtime_ns(const struct stat *st) {
#ifdef __APPLE__
return ((int64_t)st->st_mtimespec.tv_sec * DISCOVER_NS_PER_SEC) +
(int64_t)st->st_mtimespec.tv_nsec;
#elif defined(_WIN32)
return (int64_t)st->st_mtime * DISCOVER_NS_PER_SEC;
#else
return ((int64_t)st->st_mtim.tv_sec * DISCOVER_NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec;
#endif
}
static void fl_add(file_list_t *fl, const char *abs_path, const char *rel_path, CBMLanguage lang,
const struct stat *st) {
int64_t size = (int64_t)st->st_size;
if (fl->max_files >= 0 && fl->count >= fl->max_files) {
fl->limit_exceeded = true;
return;
}
uint64_t source_size = size > 0 ? (uint64_t)size : 0;
bool resource_accepted = fl->resource_policy && source_size <= (uint64_t)cbm_max_file_bytes();
if (resource_accepted) {
if (fl->resource_policy->max_files.enabled &&
fl->source_files >= fl->resource_policy->max_files.value) {
file_list_resource_violation(fl, CBM_INDEX_RESOURCE_FILES, fl->source_files + 1U,
fl->resource_policy->max_files.value);
return;
}
if (fl->resource_policy->max_source_bytes.enabled) {
uint64_t limit = fl->resource_policy->max_source_bytes.value;
if (source_size > limit || fl->source_bytes > limit - source_size) {
uint64_t observed = source_size > UINT64_MAX - fl->source_bytes
? UINT64_MAX
: fl->source_bytes + source_size;
file_list_resource_violation(fl, CBM_INDEX_RESOURCE_SOURCE_BYTES, observed, limit);
return;
}
fl->source_bytes += source_size;
}
fl->source_files++;
}
if (fl->count_only) {
fl->count++;
return;
}
if (fl->count >= fl->capacity) {
int new_cap = fl->capacity ? fl->capacity * PAIR_LEN : CBM_SZ_256;
cbm_file_info_t *new_files = realloc(fl->files, new_cap * sizeof(cbm_file_info_t));
if (!new_files) {
fl->failed = true;
return;
}
fl->files = new_files;
fl->capacity = new_cap;
}
char *path_copy = strdup(abs_path);
char *relative_copy = strdup(rel_path);
if (!path_copy || !relative_copy) {
free(path_copy);
free(relative_copy);
fl->failed = true;
return;
}
cbm_file_info_t *fi = &fl->files[fl->count++];
fi->path = path_copy;
fi->rel_path = relative_copy;
fi->language = lang;
fi->size = size;
fi->mtime_ns = discover_stat_mtime_ns(st);
}
/* ── Recursive walk ─────────────────────────────── */
/* Compute path relative to a nested .gitignore's directory.
* "webapp/src/foo.js" with prefix "webapp" → "src/foo.js". */
static const char *local_rel_path(const char *rel_path, const char *local_prefix) {
if (!local_prefix || local_prefix[0] == '\0') {
return rel_path;
}
size_t prefix_len = strlen(local_prefix);
if (strncmp(rel_path, local_prefix, prefix_len) == 0 && rel_path[prefix_len] == '/') {
return rel_path + prefix_len + SKIP_ONE;
}
return rel_path;
}
/* One .gitignore on the path from the repository root down to a walk frame.
* Links form a root->leaf chain; every frame below the directory that owns a
* matcher borrows a pointer to the deepest link governing it, so a directory
* without a .gitignore of its own simply shares its parent's link. Links live
* on the heap (the frame stack is realloc'd and popped) and walk_dir owns them
* through the `owned_next` list.
*
* Every pattern is anchored to the directory its file came from, so each link
* carries the offset between that directory and the walk-relative rel_path it
* is asked about. The two offsets are mirror images and exactly one is ever
* non-empty:
* `prefix` — the matcher's directory is at or BELOW the walk root
* ("" for the root itself): strip it from rel_path.
* `base` — the matcher's directory is ABOVE the walk root (an enclosing
* repository's ignore files, when a git-less subfolder is
* indexed): prepend the walk root's path relative to it.
* Both strings live in the flexible tail, `base` right after `prefix`. */
typedef struct gitignore_link {
const cbm_gitignore_t *gi;
const struct gitignore_link *parent;
struct gitignore_link *owned_next;
const char *base;
char prefix[];
} gitignore_link_t;
static gitignore_link_t *gitignore_link_new(const cbm_gitignore_t *gi, const char *prefix,
const char *base, const gitignore_link_t *parent,
gitignore_link_t **owned_links) {
size_t prefix_size = strlen(prefix) + SKIP_ONE;
size_t base_size = strlen(base) + SKIP_ONE;
gitignore_link_t *link = malloc(sizeof(*link) + prefix_size + base_size);
if (!link) {
return NULL;
}
link->gi = gi;
link->parent = parent;
memcpy(link->prefix, prefix, prefix_size);
memcpy(link->prefix + prefix_size, base, base_size);
link->base = link->prefix + prefix_size;
link->owned_next = *owned_links;
*owned_links = link;
return link;
}
static void gitignore_links_free(gitignore_link_t *link) {
while (link) {
gitignore_link_t *next = link->owned_next;
free(link);
link = next;
}
}
/* Verdict of every .gitignore between the repository root and the directory
* being walked, with git's precedence: the deepest file that has an opinion
* wins (a lower-level file takes precedence over every higher-level one), and
* within one file the last matching pattern wins. Returns >0 ignored, <0
* re-included by a negation, 0 when no file mentions the path. Cost is one
* match per .gitignore on the path — O(depth), never a rescan. */
static int gitignore_chain_result(const gitignore_link_t *link, const char *rel_path, bool is_dir) {
char based[CBM_SZ_4K];
for (; link; link = link->parent) {
const char *probe = local_rel_path(rel_path, link->prefix);
if (link->base[0]) {
/* Matcher from a directory above the walk root: ask it about the
* path it would see, i.e. the one its own rooted patterns are
* anchored against. */
int written = snprintf(based, sizeof(based), "%s/%s", link->base, probe);
if (written < 0 || (size_t)written >= sizeof(based)) {
continue; /* longer than this matcher can address: no opinion */
}
probe = based;
}
int verdict = cbm_gitignore_match_result(link->gi, probe, is_dir);
if (verdict != 0) {
return verdict;
}
}
return 0;
}
/* Non-negatable safety core: built-in skip dirs that a .cbmignore negation
* can NEVER un-skip. A repo-committed .cbmignore must not be able to defeat
* OOM/safety skips: .git holds VCS internals (and the info/exclude sources,
* #489), node_modules explodes discovery, and the worktree-internal dirs
* (.worktrees / .claude-worktrees, the worktree entries in ALWAYS_SKIP_DIRS)
* contain parallel checkouts of the same repo whose indexing would duplicate
* the whole codebase (#802). */
bool cbm_discover_is_safety_core_directory(const char *name) {
static const char *const SAFETY_CORE_DIRS[] = {".git", "node_modules", ".worktrees",
".claude-worktrees", NULL};
return str_in_list(name, SAFETY_CORE_DIRS);
}
/* Check if a directory entry should be skipped (hardcoded dirs + gitignore). */
/* Snapshot of the cache directory for the current walk.
*
* cbm_resolve_cache_dir() returns a pointer to a static thread-local buffer, so
* calling it once per directory — as an earlier version of this prune did —
* rewrites a buffer other code may still be holding. Resolve once at the entry
* point and compare against the copy. */
static _Thread_local char g_walk_cache_dir[CBM_SZ_4K];
static void walk_cache_dir_snapshot(void) {
const char *cache = cbm_workspace_cache_dir();
snprintf(g_walk_cache_dir, sizeof(g_walk_cache_dir), "%s", cache ? cache : "");
}
/* The cache directory holds every indexed project's graph database. When a custom
* CBM_CACHE_DIR sits inside a repository — which happens in tests and is legal in
* production — walking into it would pull other projects' databases into this
* project's file list. Prune it by absolute path.
*
* This is the narrow remedy for a concern that was briefly implemented as
* refusing any root containing the cache: refusing a whole root was too blunt,
* and not walking the cache is what the concern actually asks for. */
static bool dir_is_cache_tree(const char *abs_path) {
const char *cache = g_walk_cache_dir;
if (!cache[0] || !abs_path || !abs_path[0]) {
return false;
}
size_t n = strlen(cache);
while (n > 1 && (cache[n - 1] == '/' || cache[n - 1] == '\\')) {
n--;
}
if (strncmp(abs_path, cache, n) != 0) {
return false;
}
/* Boundary-aware so "<cache>x" is not treated as living under "<cache>". */
return abs_path[n] == '\0' || abs_path[n] == '/' || abs_path[n] == '\\';
}
static bool should_skip_directory(const char *entry_name, const char *rel_path,
const cbm_discover_opts_t *opts,
const gitignore_link_t *ignore_chain,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore) {
if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL) ||
cbm_discover_path_has_skip_suffix(rel_path)) {
/* #500: a .cbmignore negation (e.g. "!obj/") whose rule is the last
* match for this dir un-skips a built-in skip-list dir — except the
* non-negatable safety core. Fall through so .gitignore/global/local
* rules still apply to the un-skipped dir. */
bool unskipped = cbmignore && !cbm_discover_is_safety_core_directory(entry_name) &&
cbm_gitignore_match_result(cbmignore, rel_path, true) < 0;
if (!unskipped) {
return true;
}
}
if (gitignore_chain_result(ignore_chain, rel_path, true) > 0) {
return true;
}
bool global_ignored = global_gi && cbm_gitignore_matches(global_gi, rel_path, true);
if (cbmignore) {
int cbm_result = cbm_gitignore_match_result(cbmignore, rel_path, true);
if (cbm_result > 0) {
return true;
}
if (cbm_result < 0 && global_ignored) {
return false;
}
}
return global_ignored;
}
/* Check if a regular file should be skipped (filters + gitignore + size). */
/* Classify why a file is skipped. Returns a static reason string (the #963
* "purposely not indexed" class) or NULL to keep the file. Check order and
* semantics — including the .cbmignore negation un-ignoring a global-gitignore
* match — are IDENTICAL to the original boolean predicate. */
static const char *file_skip_reason(const char *entry_name, const char *rel_path,
const cbm_discover_opts_t *opts,
const gitignore_link_t *ignore_chain,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, off_t file_size) {
cbm_index_mode_t mode = opts ? opts->mode : CBM_MODE_FULL;
if (cbm_has_ignored_suffix(entry_name, mode)) {
return "ignored-suffix";
}
if (cbm_should_skip_filename(entry_name, mode)) {
return "skip-list";
}
if (cbm_matches_fast_pattern(entry_name, mode)) {
return "fast-pattern";
}
if (gitignore_chain_result(ignore_chain, rel_path, false) > 0) {
return "gitignore";
}
bool global_ignored = global_gi && cbm_gitignore_matches(global_gi, rel_path, false);
if (cbmignore) {
int cbm_result = cbm_gitignore_match_result(cbmignore, rel_path, false);
if (cbm_result > 0) {
return "cbmignore";
}
if (cbm_result < 0 && global_ignored) {
global_ignored = false;
}
}
if (opts && opts->max_file_size > 0 && file_size > opts->max_file_size) {
return "size-cap";
}
return global_ignored ? "gitignore" : NULL;
}
/* Detect language for a file: its name, and its first bytes where the name is
* not enough (cbm_language_classify; the pinned test-impact inventory uses the
* same rule on git blobs). */
static CBMLanguage detect_file_language(const char *entry_name, const char *abs_path) {
return cbm_language_for_file(entry_name, abs_path);
}
/* UTF-8-safe stat: wide API on Windows, regular stat on POSIX. */
static int wide_stat(const char *path, struct stat *st) {
#ifdef _WIN32
wchar_t *wpath = cbm_path_to_wide(path);
if (!wpath) {
return CBM_NOT_FOUND;
}
struct _stat64 wst;
int ret = _wstat64(wpath, &wst);
free(wpath);
if (ret != 0) {
return CBM_NOT_FOUND;
}
st->st_mode = wst.st_mode;
st->st_size = wst.st_size;
st->st_mtime = wst.st_mtime;
return 0;
#else
return stat(path, st);
#endif
}
/* Stat a path, skipping symlinks (POSIX) and junctions / reparse points
* (Windows). Returns 0 on success, -1 to skip. Skipping reparse points keeps
* discovery from walking through a junction that points outside the project
* root, mirroring the POSIX S_ISLNK skip. *is_symlink reports whether the
* skip (if any) was specifically the symlink/reparse-point check, as
* opposed to some other stat failure (permissions, a race with a delete). */
static int safe_stat(const char *abs_path, struct stat *st, bool *is_symlink) {
*is_symlink = false;
#ifdef _WIN32
wchar_t *wpath = cbm_path_to_wide(abs_path);
if (wpath) {
DWORD attr = GetFileAttributesW(wpath);
free(wpath);
if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) {
*is_symlink = true;
return CBM_NOT_FOUND;
}
}
return wide_stat(abs_path, st);
#else
if (lstat(abs_path, st) != 0) {
return CBM_NOT_FOUND;
}
if (S_ISLNK(st->st_mode)) {
*is_symlink = true;
return CBM_NOT_FOUND;
}
return 0;
#endif
}
/* Process a single regular file entry during directory walk. */
static void walk_dir_process_file(const char *abs_path, const char *rel_path, const char *name,
const cbm_discover_opts_t *opts,
const gitignore_link_t *ignore_chain,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, const struct stat *st,
file_list_t *out) {
const char *skip_reason =
file_skip_reason(name, rel_path, opts, ignore_chain, global_gi, cbmignore, st->st_size);
if (skip_reason) {
/* Deliberately not indexed (#963) — record so callers can surface it.
* Unsupported-language files below are NOT recorded: "no grammar for
* this extension" is not an ignore decision, and listing every
* README/asset would drown the signal. */
file_list_add_ignored(out, rel_path, skip_reason);
return;
}
CBMLanguage lang = detect_file_language(name, abs_path);
if (lang == CBM_LANG_COUNT) {
return;
}
fl_add(out, abs_path, rel_path, lang, st);
}
typedef struct {
char dir[CBM_SZ_4K];
char prefix[CBM_SZ_4K];
const gitignore_link_t *ignore_chain; /* deepest .gitignore governing this dir */
} walk_frame_t;
/* Initial capacity only — the stack grows on demand. A single directory can
* hold more pending sibling frames than any fixed cap (dotnet/runtime has 855
* subdirs in one JIT regression dir), so a hard cap here means whole-repo
* discovery failure, not a depth guard. */
#define WALK_STACK_CAP 512
typedef struct {
walk_frame_t *frames;
int top;
int cap;
} walk_stack_t;
/* Build abs/rel paths and process one directory entry. */
/* Try to load a nested .gitignore from this directory. Returns owned pointer or
* NULL. Every directory below the root is probed, whether or not an ancestor
* already contributed a matcher: git stacks ALL .gitignore files on a path, and
* skipping the deeper ones once a shallower one existed was the #1973 blow-up
* (an EMPTY storage/.gitignore hid storage/dump/.gitignore's "*", so thousands
* of git-ignored dumps were discovered and indexed until the OOM killer hit).
* The root's own .gitignore is loaded by cbm_discover (merged with info/exclude). */
static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) {
if (frame->prefix[0] == '\0') {
return NULL;
}
char gi_path[CBM_SZ_4K];
snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", frame->dir);
struct stat gi_st;
if (wide_stat(gi_path, &gi_st) == 0 && S_ISREG(gi_st.st_mode)) {
return cbm_gitignore_load(gi_path);
}
return NULL;
}
/* Push a subdirectory onto the walk stack, inheriting local gitignore
* context. Grows the stack geometrically; the caller's `parent` must not
* point into the stack array (walk_dir pops into a local copy). */
static void walk_push_subdir(walk_stack_t *ws, const char *abs_path, const char *rel_path,
const walk_frame_t *parent, file_list_t *out) {
if (ws->top >= ws->cap) {
int new_cap = ws->cap * 2;
walk_frame_t *grown = realloc(ws->frames, (size_t)new_cap * sizeof(*grown));
if (!grown) {
out->failed = true;
return;
}
ws->frames = grown;
ws->cap = new_cap;
}
walk_frame_t *slot = &ws->frames[ws->top];
int directory_length = snprintf(slot->dir, CBM_SZ_4K, "%s", abs_path);
int prefix_length = snprintf(slot->prefix, CBM_SZ_4K, "%s", rel_path);
if (directory_length <= 0 || directory_length >= CBM_SZ_4K || prefix_length < 0 ||
prefix_length >= CBM_SZ_4K) {
out->failed = true;
return;
}
slot->ignore_chain = parent->ignore_chain;
ws->top++;
}
static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *frame,
const cbm_discover_opts_t *opts,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, walk_stack_t *ws,
file_list_t *out) {
char abs_path[CBM_SZ_4K];
char rel_path[CBM_SZ_4K];
int absolute_length = snprintf(abs_path, sizeof(abs_path), "%s/%s", frame->dir, entry->name);
int relative_length;
if (frame->prefix[0] != '\0') {
relative_length = snprintf(rel_path, sizeof(rel_path), "%s/%s", frame->prefix, entry->name);
} else {
relative_length = snprintf(rel_path, sizeof(rel_path), "%s", entry->name);
}
if (absolute_length <= 0 || (size_t)absolute_length >= sizeof(abs_path) ||
relative_length <= 0 || (size_t)relative_length >= sizeof(rel_path)) {
out->failed = true;
return;
}
struct stat st;
bool is_symlink = false;
if (safe_stat(abs_path, &st, &is_symlink) != 0) {
if (out->count_only) {
out->failed = true;
} else if (is_symlink) {
/* Deliberately not indexed (#963): record so callers can
* surface it, matching the directory-exclusion and file
* skip_reason paths a few lines below. */
file_list_add_ignored(out, rel_path, "symlink");
}
return;
}
if (S_ISDIR(st.st_mode)) {
if (!dir_is_cache_tree(abs_path) &&
!should_skip_directory(entry->name, rel_path, opts, frame->ignore_chain, global_gi,
cbmignore)) {
walk_push_subdir(ws, abs_path, rel_path, frame, out);
} else {
/* Record the excluded subtree root so callers can report it (#411). */
file_list_add_excluded(out, rel_path);
}
} else if (S_ISREG(st.st_mode)) {
walk_dir_process_file(abs_path, rel_path, entry->name, opts, frame->ignore_chain, global_gi,
cbmignore, &st, out);
}
}
static bool walk_owned_gitignore_append(cbm_gitignore_t ***owned, size_t *count, size_t *capacity,
cbm_gitignore_t *gitignore) {
if (!owned || !count || !capacity || !gitignore) {
return false;
}
if (*count == *capacity) {
size_t next_capacity = *capacity == 0 ? 16U : *capacity * 2U;
if (next_capacity < *capacity || next_capacity > SIZE_MAX / sizeof(**owned)) {
return false;
}
cbm_gitignore_t **grown = realloc(*owned, next_capacity * sizeof(*grown));
if (!grown) {
return false;
}
*owned = grown;
*capacity = next_capacity;
}
(*owned)[(*count)++] = gitignore;
return true;
}
/* Release an owner array built by walk_owned_gitignore_append(). Shared by the
* walk and by the enclosing-repository chain, which own their matchers the
* same way. */
static void walk_owned_gitignore_free(cbm_gitignore_t **owned, size_t count) {
for (size_t i = 0; i < count; i++) {
cbm_gitignore_free(owned[i]);
}
free(owned);
}
static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts,
const cbm_gitignore_t *gitignore, const gitignore_link_t *ancestors,
const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore,
file_list_t *out) {
walk_stack_t ws = {
.frames = calloc(WALK_STACK_CAP, sizeof(walk_frame_t)), .top = 0, .cap = WALK_STACK_CAP};
if (!ws.frames) {
out->failed = true;
return;
}
/* Collect all owned gitignores and chain links — freed at the end because
* child frames on the stack hold borrowed pointers to them. */
cbm_gitignore_t **owned_gis = NULL;
size_t owned_count = 0;
size_t owned_capacity = 0;
gitignore_link_t *owned_links = NULL;
int initial_directory_length = snprintf(ws.frames[0].dir, CBM_SZ_4K, "%s", dir_path);
int initial_prefix_length = snprintf(ws.frames[0].prefix, CBM_SZ_4K, "%s", rel_prefix);
if (initial_directory_length <= 0 || initial_directory_length >= CBM_SZ_4K ||
initial_prefix_length < 0 || initial_prefix_length >= CBM_SZ_4K) {
out->failed = true;
free(ws.frames);
return;
}
/* `ancestors` is the deepest link of the enclosing repository's chain (or
* NULL); the walk root's own matcher sits below it so it still wins. */
ws.frames[0].ignore_chain = ancestors;
if (gitignore) {
ws.frames[0].ignore_chain =
gitignore_link_new(gitignore, rel_prefix, "", ancestors, &owned_links);
if (!ws.frames[0].ignore_chain) {
out->failed = true;
free(ws.frames);
return;
}
}
ws.top++;
while (ws.top > 0 && !file_list_should_stop(out)) {
walk_frame_t frame = ws.frames[--ws.top];
cbm_gitignore_t *loaded = try_load_nested_gitignore(&frame);
if (loaded) {
if (!walk_owned_gitignore_append(&owned_gis, &owned_count, &owned_capacity, loaded)) {
cbm_gitignore_free(loaded);
out->failed = true;
break;
}
/* owned_gis owns `loaded` from here on, even if the link fails. */
const gitignore_link_t *link =
gitignore_link_new(loaded, frame.prefix, "", frame.ignore_chain, &owned_links);
if (!link) {
out->failed = true;
break;
}
frame.ignore_chain = link;
}
cbm_dir_t *d = cbm_opendir(frame.dir);
if (!d) {
if (out->count_only) {
out->failed = true;
}
continue;
}
cbm_dirent_t *entry;
while (!file_list_should_stop(out) && (entry = cbm_readdir(d)) != NULL) {
walk_dir_process_entry(entry, &frame, opts, global_gi, cbmignore, &ws, out);
}
cbm_closedir(d);
}
walk_owned_gitignore_free(owned_gis, owned_count);
gitignore_links_free(owned_links);
free(ws.frames);
}
/* ── Public API ───────────────────────────────── */
static bool discover_path_is_absolute(const char *path) {
if (!path || !path[0]) {
return false;
}
if (path[0] == '/' || path[0] == '\\') {
return true;
}
#ifdef _WIN32
return isalpha((unsigned char)path[0]) && path[1] == ':';
#else
return false;
#endif
}
/* Resolve the shared "common" git directory for repo_path.
* Handles three layouts:
* 1. <repo>/.git is a directory - ordinary repo; common_dir == <repo>/.git
* 2. <repo>/.git is a regular file - linked worktree gitlink "gitdir: <path>";
* the common dir is read from <git_dir>/commondir (git stores info/exclude +
* config there, shared across worktrees). Falls back to git_dir when no
* commondir file exists.
* 3. neither - not a git repo.
* Returns true when a git dir was resolved. Fixes the worktree case where
* .git/info/exclude and core.excludesfile were silently dropped because the old
* check required .git to be a directory (issue #489 only covered ordinary repos). */
static bool resolve_git_common_dir(const char *repo_path, char *common_dir, size_t cd_sz) {
char dot_git[CBM_SZ_4K];
snprintf(dot_git, sizeof(dot_git), "%s/.git", repo_path);
struct stat st;
if (wide_stat(dot_git, &st) != 0) {
return false;
}
if (S_ISDIR(st.st_mode)) {
snprintf(common_dir, cd_sz, "%s", dot_git);
cbm_normalize_path_sep(common_dir);
return true;
}
if (!S_ISREG(st.st_mode)) {
return false;
}
/* Linked worktree: parse "gitdir: <path>" from the gitlink file.
* cbm_fopen (not raw fopen) so non-ASCII repo paths open on Windows. */
FILE *f = cbm_fopen(dot_git, "r");
if (!f) {
return false;
}
char git_dir[CBM_SZ_4K];
bool got_git_dir = false;
char line[CBM_SZ_4K];
while (fgets(line, sizeof(line), f)) {
char *gs = trim_ws(line);
if (strncmp(gs, "gitdir:", 7) == 0) {
char *val = trim_ws(gs + 7);
if (val[0] != '\0') {
if (discover_path_is_absolute(val)) {
snprintf(git_dir, sizeof(git_dir), "%s", val);
cbm_normalize_path_sep(git_dir);
} else {
path_join(git_dir, sizeof(git_dir), repo_path, val);
}
got_git_dir = true;
}
break;
}
}
fclose(f);
if (!got_git_dir) {
return false;
}
/* The shared dir holding info/exclude + config is named in <git_dir>/commondir
* (typically a relative path like "../.."). Absent in single-worktree gitdirs. */
char commondir_path[CBM_SZ_4K];
path_join(commondir_path, sizeof(commondir_path), git_dir, "commondir");
FILE *cf = cbm_fopen(commondir_path, "r");
if (cf) {
char cbuf[CBM_SZ_4K];
bool resolved = false;
if (fgets(cbuf, sizeof(cbuf), cf)) {
char *cs = trim_ws(cbuf);
if (cs[0] != '\0') {
if (discover_path_is_absolute(cs)) {
snprintf(common_dir, cd_sz, "%s", cs);
cbm_normalize_path_sep(common_dir);
} else {
path_join(common_dir, cd_sz, git_dir, cs);
}
resolved = true;
}
}
fclose(cf);
if (resolved) {
return true;
}
}
snprintf(common_dir, cd_sz, "%s", git_dir);
cbm_normalize_path_sep(common_dir);
return true;
}
/* When repo_path itself carries no .git (resolve_git_common_dir already
* returned false for it), walk upward looking for an enclosing repository,
* exactly as `git` itself would when run from a subfolder. Bounded by the
* filesystem/drive root: dir is truncated at each iteration, so the loop
* cannot run more times than repo_path is characters long. Returns true and
* fills ancestor_root (for the enclosing repo's own .gitignore) plus
* common_dir (for its info/exclude + config, via the same resolution
* resolve_git_common_dir already applies to an ordinary repo root). Fixes
* the remaining half of issue #510: only the indexed directory's own
* .gitignore was ever consulted, never an enclosing repo's. */
static bool resolve_enclosing_git_root(const char *repo_path, char *ancestor_root, size_t ar_sz,
char *common_dir, size_t cd_sz) {
char dir[CBM_SZ_4K];
snprintf(dir, sizeof(dir), "%s", repo_path);
cbm_normalize_path_sep(dir);
for (;;) {
char *slash = strrchr(dir, '/');
/* No separator left, or only the root separator (POSIX "/") or a
* bare drive prefix (Windows "C:/"): nothing above this to check. */
if (!slash || slash == dir || (slash > dir && *(slash - 1) == ':')) {
return false;
}
*slash = '\0';
if (resolve_git_common_dir(dir, common_dir, cd_sz)) {
snprintf(ancestor_root, ar_sz, "%s", dir);
return true;
}
}
}
/* The enclosing repository's ignore files, kept OUT of the indexed directory's
* own matcher. Merging them in would re-anchor every rooted pattern onto the
* indexed subfolder: "/secret.py" at the enclosing root would start hiding
* <subfolder>/secret.py (silent index loss), and "pkg/scratch/" would stop
* hiding pkg/scratch (a directory git excludes would be walked). Each matcher
* instead keeps its own directory as its anchor, via the link's `base`. */
typedef struct {
cbm_gitignore_t **owned;
size_t count;
size_t capacity;
const gitignore_link_t *chain; /* deepest ancestor link, or NULL */
gitignore_link_t *links; /* owner list backing `chain` */
} ancestor_ignores_t;
static void ancestor_ignores_free(ancestor_ignores_t *anc) {
walk_owned_gitignore_free(anc->owned, anc->count);
gitignore_links_free(anc->links);
}
/* Add one ancestor directory's matcher to the chain, deepest last so that the
* deepest file with an opinion wins (git's shallow-to-deep rule, negations
* included). Takes ownership of `gi`. */
static bool ancestor_ignores_push(ancestor_ignores_t *anc, cbm_gitignore_t *gi, const char *base) {
if (!walk_owned_gitignore_append(&anc->owned, &anc->count, &anc->capacity, gi)) {
cbm_gitignore_free(gi);
return false;
}
/* owned[] owns `gi` from here on, even if the link fails. */
const gitignore_link_t *link = gitignore_link_new(gi, "", base, anc->chain, &anc->links);
if (!link) {
return false;
}
anc->chain = link;
return true;
}
/* Load the .gitignore of EVERY directory from the enclosing repository root
* (inclusive) down to the indexed directory (exclusive) — git consults all of
* them, not just the root's. `root_extra` is that repository's
* <common>/info/exclude, which git anchors at its root like the root
* .gitignore; ownership is taken. `leaf` must be a canonical path under
* `root`, both with '/' separators. Returns false only on allocation
* failure. */
static bool ancestor_ignores_build(ancestor_ignores_t *anc, const char *root, const char *leaf,
cbm_gitignore_t *root_extra) {
size_t root_len = strlen(root);
if (strncmp(leaf, root, root_len) != 0) {
cbm_gitignore_free(root_extra);
return true;
}
const char *base = leaf + root_len;
while (*base == '/') {
base++;
}
char dir[CBM_SZ_4K];
int dir_len = snprintf(dir, sizeof(dir), "%s", root);
if (dir_len < 0 || (size_t)dir_len >= sizeof(dir) || !*base) {
cbm_gitignore_free(root_extra);
return dir_len >= 0 && (size_t)dir_len < sizeof(dir);
}
for (;;) {
char gi_path[CBM_SZ_4K];
path_join(gi_path, sizeof(gi_path), dir, ".gitignore");
cbm_gitignore_t *gi = cbm_gitignore_load(gi_path);
if (root_extra) {
/* First iteration: `dir` is the enclosing repository root, the one
* directory info/exclude shares an anchor with. Merged after the
* .gitignore patterns so it overrides them, the same precedence
* the two already have for an ordinary repository root. */
if (!gi) {
gi = root_extra;
} else {
(void)cbm_gitignore_merge(gi, root_extra);
cbm_gitignore_free(root_extra);
}
root_extra = NULL;
}
if (gi && !ancestor_ignores_push(anc, gi, base)) {
return false;
}
const char *slash = strchr(base, '/');
if (!slash) {
return true; /* the next directory down IS the indexed one */
}
int written = snprintf(dir + dir_len, sizeof(dir) - (size_t)dir_len, "/%.*s",
(int)(slash - base), base);
if (written < 0 || (size_t)written >= sizeof(dir) - (size_t)dir_len) {
return true; /* deeper directories are unaddressable: stop here */
}
dir_len += written;
base = slash + SKIP_ONE;
}
}
int cbm_discover(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count) {
return cbm_discover_ex(repo_path, opts, out, count, NULL, NULL);
}
int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count, char ***excluded_out, int *excluded_count_out) {
return cbm_discover_ex2(repo_path, opts, out, count, excluded_out, excluded_count_out, NULL,
NULL, NULL);
}
static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_discover_opts_t *opts,
cbm_file_info_t **out, int *count, char ***excluded_out,
int *excluded_count_out,
cbm_ignored_file_t **ignored_out, int *ignored_count_out,
int *ignored_total_out, bool count_only, int max_files,
uint64_t deadline_ms) {
if (excluded_out) {
*excluded_out = NULL;
}
if (excluded_count_out) {
*excluded_count_out = 0;
}
if (ignored_out) {
*ignored_out = NULL;
}
if (ignored_count_out) {
*ignored_count_out = 0;
}
if (ignored_total_out) {
*ignored_total_out = 0;
}
if (opts && opts->resource_violation) {
*opts->resource_violation = (cbm_index_resource_violation_t){0};
}
if (!repo_path || !out || !count || (count_only && max_files < 0)) {
return CBM_DISCOVER_ERROR;
}
*out = NULL;
*count = 0;
/* Verify directory exists */
struct stat st;
if (wide_stat(repo_path, &st) != 0 || !S_ISDIR(st.st_mode)) {
return CBM_DISCOVER_ERROR;
}
/* Load gitignore sources for ordinary repos AND linked worktrees.
* Sources merged in order (later patterns win on conflict):
* 1. <repo>/.gitignore — committed exclusions
* 2. <common>/info/exclude — per-clone exclusions, not committed
* <common> is the git common dir, resolved via resolve_git_common_dir() so a
* worktree (where .git is a gitlink file) reads the shared info/exclude/config
* just like a normal checkout. Both are folded into a single matcher so all
* downstream call paths remain unchanged. Fixes issue #489: OOM on repos whose
* worktrees are excluded only via .git/info/exclude (e.g. Sandcastle).
*
* An ENCLOSING repository's ignore files (indexing a git-less subfolder,
* issue #510) are deliberately NOT folded in here: their patterns are
* anchored to their own directories, so they stay separate matchers on the
* chain with a `base` offset. See ancestor_ignores_build(). */
cbm_gitignore_t *gitignore = NULL;
char gi_path[CBM_SZ_4K];
struct stat gi_stat;
/* Resolve the git common dir, transparently following a worktree gitlink so the
* .git/info/exclude and core.excludesfile sources are honoured inside linked
* worktrees too (where .git is a file pointing at the shared dir, not a directory). */
char git_common_dir[CBM_SZ_4K];
bool is_git_repo = resolve_git_common_dir(repo_path, git_common_dir, sizeof(git_common_dir));
bool has_git_config = false;
/* repo_path itself is not a git repo root: walk upward for an enclosing one,
* exactly as `git` does when run from a subfolder. Fixes the remaining half
* of issue #510: only the indexed directory's own .gitignore was ever
* consulted, never an enclosing repo's. The walk is lexical, so repo_path is
* canonicalized first — otherwise `cbm index pkg` and `cbm index /abs/pkg`
* would discover different files from the same directory. */
ancestor_ignores_t ancestors = {0};
char enclosing_root[CBM_SZ_4K];
char canonical_repo[CBM_SZ_4K];
bool has_enclosing_repo = false;
if (!is_git_repo) {
if (!cbm_canonical_path(repo_path, canonical_repo, sizeof(canonical_repo))) {
snprintf(canonical_repo, sizeof(canonical_repo), "%s", repo_path);
}
cbm_normalize_path_sep(canonical_repo);
has_enclosing_repo =
resolve_enclosing_git_root(canonical_repo, enclosing_root, sizeof(enclosing_root),
git_common_dir, sizeof(git_common_dir));
is_git_repo = has_enclosing_repo;
}
/* Always honour the .gitignore at the indexed-directory root, even when the
* directory is not a git repo root (e.g. indexing a sub-package directly).
* Fixes issue #510: a root .gitignore was silently ignored without .git/. */
snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", repo_path);
gitignore = cbm_gitignore_load(gi_path);
if (is_git_repo) {
path_join(gi_path, sizeof(gi_path), git_common_dir, "config");
has_git_config = wide_stat(gi_path, &gi_stat) == 0 && S_ISREG(gi_stat.st_mode);
char exc_path[CBM_SZ_4K];
path_join(exc_path, sizeof(exc_path), git_common_dir, "info/exclude");
cbm_gitignore_t *git_exclude = cbm_gitignore_load(exc_path);
if (has_enclosing_repo) {
/* git_common_dir belongs to the ENCLOSING repository, so its
* info/exclude is anchored at that repository's root, not at the
* indexed subfolder. Ownership passes to the ancestor chain. */
if (!ancestor_ignores_build(&ancestors, enclosing_root, canonical_repo, git_exclude)) {
ancestor_ignores_free(&ancestors);
cbm_gitignore_free(gitignore);
return CBM_DISCOVER_ERROR;
}
} else if (git_exclude) {
if (!gitignore) {
gitignore = git_exclude;
} else {
/* On allocation failure the merge is atomic (dst unchanged), so
* the .gitignore patterns still apply; the exclude patterns are
* simply skipped — same as if .git/info/exclude were absent. */
(void)cbm_gitignore_merge(gitignore, git_exclude);
cbm_gitignore_free(git_exclude);
}
}
}
cbm_gitignore_t *global_gi = NULL;
if (has_git_config && resolve_global_excludes_path(gi_path, sizeof(gi_path))) {
global_gi = cbm_gitignore_load(gi_path);
}
/* Load cbmignore if specified or exists at repo root */
cbm_gitignore_t *cbmignore = NULL;
if (opts && opts->ignore_file) {
cbmignore = cbm_gitignore_load(opts->ignore_file);
} else {
snprintf(gi_path, sizeof(gi_path), "%s/.cbmignore", repo_path);
cbmignore = cbm_gitignore_load(gi_path);
}
/* Walk */
file_list_t fl = {
.max_files = count_only ? max_files : -1,
.deadline_ms = count_only ? deadline_ms : 0,
.resource_policy = opts ? opts->resource_policy : NULL,
.resource_violation = opts ? opts->resource_violation : NULL,
.count_only = count_only,
.collect_excluded = !count_only && excluded_out != NULL,
.collect_ignored = !count_only && ignored_out != NULL,
};
walk_cache_dir_snapshot();
walk_dir(repo_path, "", opts, gitignore, ancestors.chain, global_gi, cbmignore, &fl);
/* Cleanup */
ancestor_ignores_free(&ancestors);
cbm_gitignore_free(gitignore);
cbm_gitignore_free(global_gi);
cbm_gitignore_free(cbmignore);
if (count_only) {
cbm_discover_free(fl.files, fl.count);
cbm_discover_free_excluded(fl.excluded, fl.excluded_count);
cbm_discover_free_ignored(fl.ignored, fl.ignored_count);
*count = fl.count;
if (fl.failed) {
return CBM_DISCOVER_ERROR;
}
return fl.limit_exceeded ? CBM_DISCOVER_LIMIT_EXCEEDED : CBM_DISCOVER_OK;
}
if (fl.failed || fl.limit_exceeded) {
cbm_discover_free(fl.files, fl.count);
cbm_discover_free_excluded(fl.excluded, fl.excluded_count);
cbm_discover_free_ignored(fl.ignored, fl.ignored_count);
return fl.limit_exceeded ? CBM_DISCOVER_LIMIT_EXCEEDED : CBM_DISCOVER_ERROR;
}
*out = fl.files;
*count = fl.count;
/* Hand the excluded-dir list to the caller, or free it if not requested. */
if (excluded_out) {
*excluded_out = fl.excluded;
if (excluded_count_out) {
*excluded_count_out = fl.excluded_count;
}
} else {
cbm_discover_free_excluded(fl.excluded, fl.excluded_count);
}
/* Same handoff for the ignored-file list (#963). */
if (ignored_out) {
*ignored_out = fl.ignored;
if (ignored_count_out) {
*ignored_count_out = fl.ignored_count;
}
if (ignored_total_out) {
*ignored_total_out = fl.ignored_total;
}
} else {
cbm_discover_free_ignored(fl.ignored, fl.ignored_count);
}
return CBM_DISCOVER_OK;
}
int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count, char ***excluded_out, int *excluded_count_out,
cbm_ignored_file_t **ignored_out, int *ignored_count_out,
int *ignored_total_out) {
return discover_impl(repo_path, opts, out, count, excluded_out, excluded_count_out, ignored_out,
ignored_count_out, ignored_total_out, false, 0, 0);
}
cbm_discover_status_t cbm_discover_count_bounded(const char *repo_path,
const cbm_discover_opts_t *opts, int max_files,
uint64_t deadline_ms, int *count_out) {
if (count_out) {
*count_out = -1;
}
if (!repo_path || !count_out || max_files < 0) {
return CBM_DISCOVER_ERROR;
}
cbm_file_info_t *files = NULL;
int count = 0;
cbm_discover_status_t status = discover_impl(repo_path, opts, &files, &count, NULL, NULL, NULL,
NULL, NULL, true, max_files, deadline_ms);
cbm_discover_free(files, count);
*count_out = status == CBM_DISCOVER_ERROR ? -1 : count;
return status;
}
void cbm_discover_free(cbm_file_info_t *files, int count) {
if (!files) {
return;
}
for (int i = 0; i < count; i++) {
free(files[i].path);
free(files[i].rel_path);
}
free(files);
}
void cbm_discover_free_excluded(char **excluded, int count) {
if (!excluded) {
return;
}
for (int i = 0; i < count; i++) {
free(excluded[i]);
}
free(excluded);
}
void cbm_discover_free_ignored(cbm_ignored_file_t *ignored, int count) {
if (!ignored) {
return;
}
for (int i = 0; i < count; i++) {
free(ignored[i].rel_path);
free(ignored[i].reason);
}
free(ignored);
}