fix: honor git global core.excludesFile as an ignore source

Implements 2026-06-20-013-fix-global-core-excludesfile-ignore-source.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-06-20 18:48:47 -07:00
parent e599df1d56
commit 3975d7950d
3 changed files with 454 additions and 22 deletions
+255 -18
View File
@@ -13,15 +13,19 @@
#include "foundation/constants.h"
#include "foundation/compat_fs.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[] = {
@@ -131,6 +135,210 @@ 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 = 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(const char *repo_path, char *out, size_t out_sz) {
char config_path[CBM_SZ_4K];
path_join(config_path, sizeof(config_path), repo_path, ".git/config");
if (read_core_excludes_file(config_path, out, out_sz)) {
return true;
}
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;
}
/* ── Public filter functions ─────────────────────── */
bool cbm_should_skip_dir(const char *dirname, cbm_index_mode_t mode) {
@@ -273,6 +481,7 @@ static const char *local_rel_path(const char *rel_path, const char *local_prefix
/* Check if a directory entry should be skipped (hardcoded dirs + gitignore). */
static bool should_skip_directory(const char *entry_name, const char *rel_path,
const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi,
const char *local_gi_prefix) {
if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL)) {
@@ -281,23 +490,31 @@ static bool should_skip_directory(const char *entry_name, const char *rel_path,
if (gitignore && cbm_gitignore_matches(gitignore, rel_path, true)) {
return true;
}
bool global_ignored = global_gi && cbm_gitignore_matches(global_gi, rel_path, true);
if (local_gi) {
const char *lrel = local_rel_path(rel_path, local_gi_prefix);
if (cbm_gitignore_matches(local_gi, lrel, true)) {
return true;
}
}
if (cbmignore && cbm_gitignore_matches(cbmignore, rel_path, true)) {
return 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 false;
return global_ignored;
}
/* Check if a regular file should be skipped (filters + gitignore + size). */
static bool should_skip_file(const char *entry_name, const char *rel_path,
const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore,
const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi,
const char *local_gi_prefix, off_t file_size) {
const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore,
const cbm_gitignore_t *local_gi, const char *local_gi_prefix,
off_t file_size) {
cbm_index_mode_t mode = opts ? opts->mode : CBM_MODE_FULL;
if (cbm_has_ignored_suffix(entry_name, mode)) {
return true;
@@ -311,19 +528,26 @@ static bool should_skip_file(const char *entry_name, const char *rel_path,
if (gitignore && cbm_gitignore_matches(gitignore, rel_path, false)) {
return true;
}
bool global_ignored = global_gi && cbm_gitignore_matches(global_gi, rel_path, false);
if (local_gi) {
const char *lrel = local_rel_path(rel_path, local_gi_prefix);
if (cbm_gitignore_matches(local_gi, lrel, false)) {
return true;
}
}
if (cbmignore && cbm_gitignore_matches(cbmignore, rel_path, false)) {
return true;
if (cbmignore) {
int cbm_result = cbm_gitignore_match_result(cbmignore, rel_path, false);
if (cbm_result > 0) {
return true;
}
if (cbm_result < 0 && global_ignored) {
global_ignored = false;
}
}
if (opts && opts->max_file_size > 0 && file_size > opts->max_file_size) {
return true;
}
return false;
return global_ignored;
}
/* Detect language for a file, handling .m disambiguation and JSON filtering. */
@@ -384,10 +608,11 @@ static int safe_stat(const char *abs_path, struct stat *st) {
/* 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 cbm_gitignore_t *gitignore,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, const cbm_gitignore_t *local_gi,
const char *local_gi_prefix, off_t size, file_list_t *out) {
if (should_skip_file(name, rel_path, opts, gitignore, cbmignore, local_gi, local_gi_prefix,
size)) {
if (should_skip_file(name, rel_path, opts, gitignore, global_gi, cbmignore, local_gi,
local_gi_prefix, size)) {
return;
}
CBMLanguage lang = detect_file_language(name, abs_path);
@@ -435,6 +660,7 @@ static void walk_push_subdir(walk_frame_t *stack, int *top, const char *abs_path
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 *gitignore,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, walk_frame_t *stack, int *top,
file_list_t *out) {
char abs_path[CBM_SZ_4K];
@@ -452,7 +678,7 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
}
if (S_ISDIR(st.st_mode)) {
if (!should_skip_directory(entry->name, rel_path, opts, gitignore, cbmignore,
if (!should_skip_directory(entry->name, rel_path, opts, gitignore, global_gi, cbmignore,
frame->local_gi, frame->local_gi_prefix)) {
walk_push_subdir(stack, top, abs_path, rel_path, frame);
} else {
@@ -460,16 +686,16 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
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, gitignore, cbmignore,
frame->local_gi, frame->local_gi_prefix, st.st_size, out);
walk_dir_process_file(abs_path, rel_path, entry->name, opts, gitignore, global_gi,
cbmignore, frame->local_gi, frame->local_gi_prefix, st.st_size, out);
}
}
enum { GI_OWNED_CAP = 64 };
static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts,
const cbm_gitignore_t *gitignore, const cbm_gitignore_t *cbmignore,
file_list_t *out) {
const cbm_gitignore_t *gitignore, const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, file_list_t *out) {
walk_frame_t *stack = calloc(WALK_STACK_CAP, sizeof(walk_frame_t));
if (!stack) {
return;
@@ -503,7 +729,8 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis
cbm_dirent_t *entry;
while ((entry = cbm_readdir(d)) != NULL) {
walk_dir_process_entry(entry, &frame, opts, gitignore, cbmignore, stack, &top, out);
walk_dir_process_entry(entry, &frame, opts, gitignore, global_gi, cbmignore, stack,
&top, out);
}
cbm_closedir(d);
}
@@ -546,11 +773,20 @@ int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_
char gi_path[CBM_SZ_4K];
snprintf(gi_path, sizeof(gi_path), "%s/.git", repo_path);
struct stat gi_stat;
if (wide_stat(gi_path, &gi_stat) == 0 && S_ISDIR(gi_stat.st_mode)) {
bool is_git_repo = wide_stat(gi_path, &gi_stat) == 0 && S_ISDIR(gi_stat.st_mode);
bool has_git_config = false;
if (is_git_repo) {
snprintf(gi_path, sizeof(gi_path), "%s/.git/config", repo_path);
has_git_config = wide_stat(gi_path, &gi_stat) == 0 && S_ISREG(gi_stat.st_mode);
snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", repo_path);
gitignore = cbm_gitignore_load(gi_path);
}
cbm_gitignore_t *global_gi = NULL;
if (has_git_config && resolve_global_excludes_path(repo_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) {
@@ -562,10 +798,11 @@ int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_
/* Walk */
file_list_t fl = {0};
walk_dir(repo_path, "", opts, gitignore, cbmignore, &fl);
walk_dir(repo_path, "", opts, gitignore, global_gi, cbmignore, &fl);
/* Cleanup */
cbm_gitignore_free(gitignore);
cbm_gitignore_free(global_gi);
cbm_gitignore_free(cbmignore);
*out = fl.files;
+8 -4
View File
@@ -340,16 +340,16 @@ static bool match_unrooted(const char *pattern, const char *rel_path, const char
return false;
}
bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
if (!gi || !rel_path) {
return false;
return 0;
}
/* Extract the basename for non-rooted pattern matching */
const char *basename = strrchr(rel_path, '/');
basename = basename ? basename + SKIP_ONE : rel_path;
bool matched = false;
int matched = 0;
for (int i = 0; i < gi->count; i++) {
const gi_pattern_t *p = &gi->patterns[i];
@@ -362,13 +362,17 @@ bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool
: match_unrooted(p->pattern, rel_path, basename);
if (this_match) {
matched = !p->negated;
matched = p->negated ? -1 : 1;
}
}
return matched;
}
bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
return cbm_gitignore_match_result(gi, rel_path, is_dir) > 0;
}
void cbm_gitignore_free(cbm_gitignore_t *gi) {
if (!gi) {
return;
+191
View File
@@ -7,6 +7,45 @@
#include "test_helpers.h"
#include "discover/discover.h"
typedef struct {
char *home;
char *xdg_config_home;
} git_env_snapshot_t;
static git_env_snapshot_t save_git_env(void) {
git_env_snapshot_t snapshot = {0};
const char *home = getenv("HOME");
const char *xdg = getenv("XDG_CONFIG_HOME");
snapshot.home = home ? cbm_strdup(home) : NULL;
snapshot.xdg_config_home = xdg ? cbm_strdup(xdg) : NULL;
return snapshot;
}
static void restore_git_env(git_env_snapshot_t *snapshot) {
if (snapshot->home) {
cbm_setenv("HOME", snapshot->home, 1);
free(snapshot->home);
} else {
cbm_unsetenv("HOME");
}
if (snapshot->xdg_config_home) {
cbm_setenv("XDG_CONFIG_HOME", snapshot->xdg_config_home, 1);
free(snapshot->xdg_config_home);
} else {
cbm_unsetenv("XDG_CONFIG_HOME");
}
}
static bool discover_has_rel_path(const cbm_file_info_t *files, int count, const char *rel_path) {
for (int i = 0; i < count; i++) {
if (strcmp(files[i].rel_path, rel_path) == 0) {
return true;
}
}
return false;
}
/* ── Directory skip (always skipped) ───────────────────────────── */
TEST(skip_git) {
@@ -332,6 +371,154 @@ TEST(discover_with_gitignore) {
PASS();
}
TEST(discover_with_global_xdg_ignore) {
git_env_snapshot_t env = save_git_env();
char *tmp = th_mktempdir("cbm_disc_global_xdg");
ASSERT(tmp != NULL);
char base[512], repo[512], home[512], xdg[512], xdg_env[512];
snprintf(base, sizeof(base), "%s", tmp);
snprintf(repo, sizeof(repo), "%s/repo", base);
snprintf(home, sizeof(home), "%s/home", base);
snprintf(xdg, sizeof(xdg), "%s/xdg", base);
snprintf(xdg_env, sizeof(xdg_env), "%s/", xdg);
cbm_setenv("HOME", home, 1);
cbm_setenv("XDG_CONFIG_HOME", xdg_env, 1);
th_mkdir_p(TH_PATH(repo, ".git"));
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
th_write_file(TH_PATH(xdg, "git/ignore"), "secret.go\nignored_dir/\n");
th_write_file(TH_PATH(repo, "main.go"), "package main\n");
th_write_file(TH_PATH(repo, "secret.go"), "package secret\n");
th_write_file(TH_PATH(repo, "ignored_dir/thing.go"), "package ignored\n");
cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(repo, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
ASSERT_FALSE(discover_has_rel_path(files, count, "secret.go"));
ASSERT_FALSE(discover_has_rel_path(files, count, "ignored_dir/thing.go"));
cbm_discover_free(files, count);
restore_git_env(&env);
th_cleanup(base);
PASS();
}
TEST(discover_global_excludesfile_from_gitconfig_tilde) {
git_env_snapshot_t env = save_git_env();
char *tmp = th_mktempdir("cbm_disc_global_cfg");
ASSERT(tmp != NULL);
char base[512], repo[512], home[512];
snprintf(base, sizeof(base), "%s", tmp);
snprintf(repo, sizeof(repo), "%s/repo", base);
snprintf(home, sizeof(home), "%s/home", base);
cbm_setenv("HOME", home, 1);
cbm_unsetenv("XDG_CONFIG_HOME");
th_mkdir_p(TH_PATH(repo, ".git"));
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
th_write_file(TH_PATH(home, ".gitconfig"), "[core]\n excludesFile = ~/custom-ignore\n");
th_write_file(TH_PATH(home, "custom-ignore"), "skip-me.go\n");
th_write_file(TH_PATH(repo, "keep.go"), "package keep\n");
th_write_file(TH_PATH(repo, "skip-me.go"), "package skip\n");
cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(repo, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(discover_has_rel_path(files, count, "keep.go"));
ASSERT_FALSE(discover_has_rel_path(files, count, "skip-me.go"));
cbm_discover_free(files, count);
restore_git_env(&env);
th_cleanup(base);
PASS();
}
TEST(discover_missing_global_excludes_is_noop) {
git_env_snapshot_t env = save_git_env();
char *tmp = th_mktempdir("cbm_disc_global_missing");
ASSERT(tmp != NULL);
char base[512], repo[512], home[512];
snprintf(base, sizeof(base), "%s", tmp);
snprintf(repo, sizeof(repo), "%s/repo", base);
snprintf(home, sizeof(home), "%s/home", base);
cbm_setenv("HOME", home, 1);
cbm_unsetenv("XDG_CONFIG_HOME");
th_mkdir_p(TH_PATH(repo, ".git"));
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
th_write_file(TH_PATH(repo, "main.go"), "package main\n");
th_write_file(TH_PATH(repo, "would-be-global.go"), "package global\n");
cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(repo, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 2);
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
ASSERT_TRUE(discover_has_rel_path(files, count, "would-be-global.go"));
cbm_discover_free(files, count);
restore_git_env(&env);
th_cleanup(base);
PASS();
}
TEST(discover_cbmignore_negates_global_ignore) {
git_env_snapshot_t env = save_git_env();
char *tmp = th_mktempdir("cbm_disc_global_neg");
ASSERT(tmp != NULL);
char base[512], repo[512], home[512], xdg[512];
snprintf(base, sizeof(base), "%s", tmp);
snprintf(repo, sizeof(repo), "%s/repo", base);
snprintf(home, sizeof(home), "%s/home", base);
snprintf(xdg, sizeof(xdg), "%s/xdg", base);
cbm_setenv("HOME", home, 1);
cbm_setenv("XDG_CONFIG_HOME", xdg, 1);
th_mkdir_p(TH_PATH(repo, ".git"));
th_write_file(TH_PATH(repo, ".git/config"), "[core]\n");
th_write_file(TH_PATH(xdg, "git/ignore"), "rescued.go\nblocked.go\n");
th_write_file(TH_PATH(repo, ".cbmignore"), "!rescued.go\n");
th_write_file(TH_PATH(repo, "main.go"), "package main\n");
th_write_file(TH_PATH(repo, "rescued.go"), "package rescued\n");
th_write_file(TH_PATH(repo, "blocked.go"), "package blocked\n");
cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(repo, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 2);
ASSERT_TRUE(discover_has_rel_path(files, count, "main.go"));
ASSERT_TRUE(discover_has_rel_path(files, count, "rescued.go"));
ASSERT_FALSE(discover_has_rel_path(files, count, "blocked.go"));
cbm_discover_free(files, count);
restore_git_env(&env);
th_cleanup(base);
PASS();
}
/* issue #234: a directory listed in the root .gitignore (e.g. "vendor/") must
* be excluded from discovery even when untracked — Composer/PHP projects rely
* on this. */
@@ -778,6 +965,10 @@ SUITE(discover) {
RUN_TEST(discover_simple);
RUN_TEST(discover_skips_git_dir);
RUN_TEST(discover_with_gitignore);
RUN_TEST(discover_with_global_xdg_ignore);
RUN_TEST(discover_global_excludesfile_from_gitconfig_tilde);
RUN_TEST(discover_missing_global_excludes_is_noop);
RUN_TEST(discover_cbmignore_negates_global_ignore);
RUN_TEST(discover_gitignore_dir_excluded_issue234);
RUN_TEST(discover_max_file_size);
RUN_TEST(discover_null_path);