mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-12 09:47:20 +08:00
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>
1628 lines
62 KiB
C
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);
|
|
}
|