diff --git a/src/discover/discover.c b/src/discover/discover.c index 314c00c5..c37a0212 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -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 #include // int64_t #include #include #include // strdup #include +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,205 @@ 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(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; +} + /* ── Public filter functions ─────────────────────── */ bool cbm_should_skip_dir(const char *dirname, cbm_index_mode_t mode) { @@ -273,6 +476,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 +485,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 +523,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 +603,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 +655,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 +673,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 +681,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 +724,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 +768,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(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 +793,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; diff --git a/src/discover/gitignore.c b/src/discover/gitignore.c index 0ef905a4..bf4a7063 100644 --- a/src/discover/gitignore.c +++ b/src/discover/gitignore.c @@ -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; diff --git a/tests/test_discover.c b/tests/test_discover.c index af51a928..498e7741 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -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,191 @@ 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_repo_local_excludesfile_is_ignored) { + git_env_snapshot_t env = save_git_env(); + char *tmp = th_mktempdir("cbm_disc_repo_cfg_ignored"); + ASSERT(tmp != NULL); + + char base[512], repo[512], home[512], secret[512], config[1024]; + snprintf(base, sizeof(base), "%s", tmp); + snprintf(repo, sizeof(repo), "%s/repo", base); + snprintf(home, sizeof(home), "%s/home", base); + snprintf(secret, sizeof(secret), "%s/secret-ignore", home); + snprintf(config, sizeof(config), "[core]\n excludesFile = %s\n", secret); + + cbm_setenv("HOME", home, 1); + cbm_unsetenv("XDG_CONFIG_HOME"); + + th_mkdir_p(TH_PATH(repo, ".git")); + th_write_file(TH_PATH(repo, ".git/config"), config); + th_write_file(secret, "skip-me.go\n"); + th_write_file(TH_PATH(repo, "keep.go"), "package keep\n"); + th_write_file(TH_PATH(repo, "skip-me.go"), "package skip\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + + int rc = cbm_discover(repo, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 2); + ASSERT_TRUE(discover_has_rel_path(files, count, "keep.go")); + ASSERT_TRUE(discover_has_rel_path(files, count, "skip-me.go")); + + cbm_discover_free(files, count); + restore_git_env(&env); + th_cleanup(base); + PASS(); +} + +TEST(discover_missing_global_excludes_is_noop) { + git_env_snapshot_t env = save_git_env(); + char *tmp = th_mktempdir("cbm_disc_global_missing"); + ASSERT(tmp != NULL); + + char base[512], repo[512], home[512]; + snprintf(base, sizeof(base), "%s", tmp); + snprintf(repo, sizeof(repo), "%s/repo", base); + snprintf(home, sizeof(home), "%s/home", base); + + cbm_setenv("HOME", home, 1); + cbm_unsetenv("XDG_CONFIG_HOME"); + + th_mkdir_p(TH_PATH(repo, ".git")); + th_write_file(TH_PATH(repo, ".git/config"), "[core]\n"); + th_write_file(TH_PATH(repo, "main.go"), "package main\n"); + th_write_file(TH_PATH(repo, "would-be-global.go"), "package global\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + + int rc = cbm_discover(repo, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 2); + ASSERT_TRUE(discover_has_rel_path(files, count, "main.go")); + ASSERT_TRUE(discover_has_rel_path(files, count, "would-be-global.go")); + + cbm_discover_free(files, count); + restore_git_env(&env); + th_cleanup(base); + PASS(); +} + +TEST(discover_cbmignore_negates_global_ignore) { + git_env_snapshot_t env = save_git_env(); + char *tmp = th_mktempdir("cbm_disc_global_neg"); + ASSERT(tmp != NULL); + + char base[512], repo[512], home[512], xdg[512]; + snprintf(base, sizeof(base), "%s", tmp); + snprintf(repo, sizeof(repo), "%s/repo", base); + snprintf(home, sizeof(home), "%s/home", base); + snprintf(xdg, sizeof(xdg), "%s/xdg", base); + + cbm_setenv("HOME", home, 1); + cbm_setenv("XDG_CONFIG_HOME", xdg, 1); + + th_mkdir_p(TH_PATH(repo, ".git")); + th_write_file(TH_PATH(repo, ".git/config"), "[core]\n"); + th_write_file(TH_PATH(xdg, "git/ignore"), "rescued.go\nblocked.go\n"); + th_write_file(TH_PATH(repo, ".cbmignore"), "!rescued.go\n"); + th_write_file(TH_PATH(repo, "main.go"), "package main\n"); + th_write_file(TH_PATH(repo, "rescued.go"), "package rescued\n"); + th_write_file(TH_PATH(repo, "blocked.go"), "package blocked\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + + int rc = cbm_discover(repo, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 2); + ASSERT_TRUE(discover_has_rel_path(files, count, "main.go")); + ASSERT_TRUE(discover_has_rel_path(files, count, "rescued.go")); + ASSERT_FALSE(discover_has_rel_path(files, count, "blocked.go")); + + cbm_discover_free(files, count); + restore_git_env(&env); + th_cleanup(base); + PASS(); +} + /* issue #234: a directory listed in the root .gitignore (e.g. "vendor/") must * be excluded from discovery even when untracked — Composer/PHP projects rely * on this. */ @@ -778,6 +1002,11 @@ 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_repo_local_excludesfile_is_ignored); + RUN_TEST(discover_missing_global_excludes_is_noop); + RUN_TEST(discover_cbmignore_negates_global_ignore); RUN_TEST(discover_gitignore_dir_excluded_issue234); RUN_TEST(discover_max_file_size); RUN_TEST(discover_null_path);