Break-glass merge PR #2608

This commit is contained in:
Martin Vogel
2026-10-12 20:12:55 +02:00
committed by GitHub
2 changed files with 271 additions and 25 deletions
+200 -25
View File
@@ -14,13 +14,21 @@
#include "cbm.h" // CBMLanguage, CBM_LANG_COUNT, CBM_LANG_JSON
#include "foundation/constants.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/hash_table.h"
#include "foundation/limits.h"
#include "foundation/mem_core.h"
#include "foundation/str_util.h"
#include "foundation/subprocess.h"
#include "foundation/workspace.h"
#include "foundation/platform.h"
#include "mcp/test_impact_engine.h" // cbm_test_impact_find_git
#ifdef _WIN32
#include "foundation/win_utf8.h"
#include <io.h> // _close
#else
#include <unistd.h> // close
#endif
#include <ctype.h>
#include <stdint.h> // int64_t
@@ -436,6 +444,25 @@ bool cbm_matches_fast_pattern(const char *filename, cbm_index_mode_t mode) {
/* ── Dynamic file list ────────────────────────── */
/* Paths git tracks under the discovery root, and every directory above one.
* git applies ignore rules to untracked paths only, so .gitignore,
* info/exclude and the global excludes file never hide a tracked path. Both
* tables stay NULL when the root is not in a git repository or git cannot
* run, and the ignore rules then apply exactly as before. */
typedef struct {
char *paths; /* `git ls-files -z` output; holds the keys of `files` */
CBMHashTable *files; /* tracked path */
CBMHashTable *dirs; /* directory above a tracked path (owned key) */
} discover_tracked_t;
static bool tracked_has_file(const discover_tracked_t *tracked, const char *rel_path) {
return tracked && tracked->files && cbm_ht_has(tracked->files, rel_path);
}
static bool tracked_has_dir(const discover_tracked_t *tracked, const char *rel_path) {
return tracked && tracked->dirs && cbm_ht_has(tracked->dirs, rel_path);
}
typedef struct {
cbm_file_info_t *files;
int count;
@@ -465,6 +492,7 @@ typedef struct {
int ignored_count;
int ignored_cap;
int ignored_total;
const discover_tracked_t *tracked;
} file_list_t;
static void file_list_resource_violation(file_list_t *fl, cbm_index_resource_t resource,
@@ -765,11 +793,16 @@ static bool dir_is_cache_tree(const char *abs_path) {
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) {
/* What the ignore rules say about a directory. DIR_GIT_IGNORED comes from
* git's own rules (.gitignore files, info/exclude, the global excludes file),
* which yield to tracked content; built-in skips and .cbmignore never do. */
typedef enum { DIR_KEEP, DIR_SKIP, DIR_GIT_IGNORED } dir_verdict_t;
static dir_verdict_t directory_verdict(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
@@ -779,35 +812,36 @@ static bool should_skip_directory(const char *entry_name, const char *rel_path,
bool unskipped = cbmignore && !cbm_discover_is_safety_core_directory(entry_name) &&
cbm_gitignore_match_result(cbmignore, rel_path, true) < 0;
if (!unskipped) {
return true;
return DIR_SKIP;
}
}
if (gitignore_chain_result(ignore_chain, rel_path, true) > 0) {
return true;
}
bool git_ignored = gitignore_chain_result(ignore_chain, rel_path, true) > 0;
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;
return DIR_SKIP;
}
if (cbm_result < 0 && global_ignored) {
return false;
if (cbm_result < 0) {
global_ignored = false; /* a .cbmignore negation un-ignores a global match */
}
}
return global_ignored;
return git_ignored || global_ignored ? DIR_GIT_IGNORED : DIR_KEEP;
}
/* 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. */
* match — are IDENTICAL to the original boolean predicate. git's own rules
* never hide a tracked file; inside a git-ignored directory (entered only for
* its tracked content) every untracked file is ignored, as in git. */
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) {
const cbm_gitignore_t *cbmignore, off_t file_size, bool tracked,
bool in_ignored_dir) {
cbm_index_mode_t mode = opts ? opts->mode : CBM_MODE_FULL;
if (cbm_has_ignored_suffix(entry_name, mode)) {
return "ignored-suffix";
@@ -818,10 +852,11 @@ static const char *file_skip_reason(const char *entry_name, const char *rel_path
if (cbm_matches_fast_pattern(entry_name, mode)) {
return "fast-pattern";
}
if (gitignore_chain_result(ignore_chain, rel_path, false) > 0) {
if (!tracked && (in_ignored_dir || gitignore_chain_result(ignore_chain, rel_path, false) > 0)) {
return "gitignore";
}
bool global_ignored = global_gi && cbm_gitignore_matches(global_gi, rel_path, false);
bool global_ignored =
!tracked && 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) {
@@ -1049,9 +1084,10 @@ static void walk_dir_process_file(const char *abs_path, const char *rel_path, co
const gitignore_link_t *ignore_chain,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, const sphinx_suffixes_t *sphinx,
const struct stat *st, file_list_t *out) {
bool in_ignored_dir, 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);
file_skip_reason(name, rel_path, opts, ignore_chain, global_gi, cbmignore, st->st_size,
tracked_has_file(out->tracked, rel_path), in_ignored_dir);
if (skip_reason) {
/* Deliberately not indexed (#963) — record so callers can surface it.
* Unsupported-language files below are NOT recorded: "no grammar for
@@ -1075,6 +1111,7 @@ typedef struct {
char prefix[CBM_SZ_4K];
const gitignore_link_t *ignore_chain; /* deepest .gitignore governing this dir */
sphinx_suffixes_t sphinx; /* the documentation set's extra reST suffixes */
bool git_ignored; /* ignored by git's rules, entered only for its tracked content */
} 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
@@ -1112,7 +1149,7 @@ static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) {
* 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) {
const walk_frame_t *parent, bool git_ignored, 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));
@@ -1133,6 +1170,7 @@ static void walk_push_subdir(walk_stack_t *ws, const char *abs_path, const char
}
slot->ignore_chain = parent->ignore_chain;
slot->sphinx = parent->sphinx;
slot->git_ignored = git_ignored;
ws->top++;
}
@@ -1171,17 +1209,23 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
}
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);
dir_verdict_t verdict = dir_is_cache_tree(abs_path)
? DIR_SKIP
: directory_verdict(entry->name, rel_path, opts,
frame->ignore_chain, global_gi, cbmignore);
if (verdict == DIR_KEEP && frame->git_ignored) {
verdict = DIR_GIT_IGNORED; /* below a git-ignored directory, as in git */
}
if (verdict == DIR_KEEP ||
(verdict == DIR_GIT_IGNORED && tracked_has_dir(out->tracked, rel_path))) {
walk_push_subdir(ws, abs_path, rel_path, frame, verdict == DIR_GIT_IGNORED, 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, &frame->sphinx, &st, out);
cbmignore, &frame->sphinx, frame->git_ignored, &st, out);
}
}
@@ -1315,6 +1359,131 @@ static bool discover_path_is_absolute(const char *path) {
#endif
}
static void tracked_free_key(const char *key, void *value, void *userdata) {
(void)value;
(void)userdata;
cbm_free(CBM_MEM_CLASS_OTHER, (void *)key);
}
static void tracked_free(discover_tracked_t *tracked) {
if (tracked->dirs) {
cbm_ht_foreach(tracked->dirs, tracked_free_key, NULL);
}
cbm_ht_free(tracked->dirs);
cbm_ht_free(tracked->files);
cbm_free(CBM_MEM_CLASS_OTHER, tracked->paths);
memset(tracked, 0, sizeof(*tracked));
}
/* The bytes of the capture file at path, NUL-terminated; NULL on any failure. */
static char *tracked_slurp(const char *path, size_t *length) {
int64_t size = cbm_file_size(path);
if (size < 0 || (uint64_t)size >= SIZE_MAX) {
return NULL;
}
char *buffer = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)size + SKIP_ONE);
FILE *fp = buffer ? cbm_fopen(path, "rb") : NULL;
bool ok = fp && fread(buffer, 1, (size_t)size, fp) == (size_t)size;
if (fp) {
(void)fclose(fp);
}
if (!ok) {
cbm_free(CBM_MEM_CLASS_OTHER, buffer);
return NULL;
}
buffer[size] = '\0';
*length = (size_t)size;
return buffer;
}
/* The whole `git ls-files -z` output for repo_path (paths relative to it),
* NUL-terminated; NULL when git cannot run or fails. Git runs without a
* shell: the repository path is one argv entry, and the child environment
* drops repository-local variables so an inherited GIT_DIR cannot redirect -C. */
static char *tracked_read(const char *repo_path, size_t *length) {
*length = 0;
char git[CBM_SZ_4K];
char capture[CBM_SZ_4K];
int n = snprintf(capture, sizeof(capture), "%s/cbm-discover-git-XXXXXX", cbm_tmpdir());
if (!cbm_test_impact_find_git(git, sizeof(git)) || n <= 0 || (size_t)n >= sizeof(capture)) {
return NULL;
}
int fd = cbm_mkstemp(capture);
if (fd < 0) {
return NULL;
}
#ifdef _WIN32
(void)_close(fd);
#else
(void)close(fd);
#endif
const char *argv[] = {git, "-C", repo_path, "-c", "core.fsmonitor=false",
"ls-files", "-z", NULL};
cbm_proc_opts_t opts = {.bin = git,
.argv = argv,
.cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS,
.strip_git_repo_env = true,
.stdout_file = capture};
cbm_proc_result_t result = {0};
bool ran = cbm_subprocess_run(&opts, &result) == 0 && result.outcome == CBM_PROC_CLEAN &&
result.exit_code == 0;
char *buffer = ran ? tracked_slurp(capture, length) : NULL;
(void)cbm_unlink(capture);
return buffer;
}
/* Record every directory above `path` (its '/'-separated prefixes). */
static bool tracked_add_dirs(CBMHashTable *dirs, const char *path) {
char prefix[CBM_SZ_4K];
for (const char *slash = strchr(path, '/'); slash; slash = strchr(slash + SKIP_ONE, '/')) {
size_t n = (size_t)(slash - path);
if (n == 0 || n >= sizeof(prefix)) {
continue;
}
memcpy(prefix, path, n);
prefix[n] = '\0';
if (cbm_ht_has(dirs, prefix)) {
continue;
}
char *key = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, prefix);
if (!key) {
return false;
}
cbm_ht_set(dirs, key, key);
}
return true;
}
/* Load what git tracks under repo_path; leaves `tracked` empty on any failure,
* so the ignore rules then apply exactly as they did without it. */
static void tracked_load(discover_tracked_t *tracked, const char *repo_path) {
memset(tracked, 0, sizeof(*tracked));
size_t length = 0;
tracked->paths = tracked_read(repo_path, &length);
if (!tracked->paths || length == 0) {
tracked_free(tracked);
return;
}
tracked->files = cbm_ht_create(CBM_SZ_1K);
tracked->dirs = cbm_ht_create(CBM_SZ_256);
if (!tracked->files || !tracked->dirs) {
tracked_free(tracked);
return;
}
for (size_t at = 0; at < length;) {
const char *path = tracked->paths + at;
size_t n = strlen(path);
if (n > 0) {
cbm_ht_set(tracked->files, path, (void *)path);
if (!tracked_add_dirs(tracked->dirs, path)) {
tracked_free(tracked);
return;
}
}
at += n + SKIP_ONE;
}
}
/* Resolve the shared "common" git directory for repo_path.
* Handles three layouts:
* 1. <repo>/.git is a directory - ordinary repo; common_dir == <repo>/.git
@@ -1679,10 +1848,16 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
.collect_excluded = !count_only && excluded_out != NULL,
.collect_ignored = !count_only && ignored_out != NULL,
};
discover_tracked_t tracked = {0};
if (is_git_repo) {
tracked_load(&tracked, repo_path);
fl.tracked = &tracked;
}
walk_cache_dir_snapshot();
walk_dir(repo_path, "", opts, gitignore, ancestors.chain, global_gi, cbmignore, &fl);
/* Cleanup */
tracked_free(&tracked);
ancestor_ignores_free(&ancestors);
cbm_gitignore_free(gitignore);
cbm_gitignore_free(global_gi);
+71
View File
@@ -891,6 +891,76 @@ TEST(discover_gitignore_dir_excluded_issue234) {
PASS();
}
static int disc_git(const char *dir, const char *args) {
char cmd[1024];
snprintf(cmd, sizeof(cmd),
"git -C \"%s\" -c user.name=t -c user.email=t@t.io -c init.defaultBranch=master %s",
dir, args);
return system(cmd);
}
static bool disc_has(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;
}
/* A .gitignore never hides a file git tracks: git applies ignore rules to
* untracked paths only. cluster-api's nested `.gitignore` line `manager`,
* meant for a built binary, hid its tracked config/manager/ kustomization.
* Untracked ignored files stay out, also inside a directory that is entered
* only for its tracked content. */
TEST(discover_gitignore_never_hides_tracked_files) {
char *base = th_mktempdir("cbm_disc_tracked");
ASSERT(base != NULL);
if (disc_git(base, "init -q") != 0) {
th_cleanup(base);
FAIL("git init failed");
}
th_write_file(TH_PATH(base, ".gitignore"), "manager\n*.gen.go\n");
th_write_file(TH_PATH(base, "main.go"), "package main\n\nfunc main() {}\n");
th_write_file(TH_PATH(base, "config/manager/manager.go"), "package manager\n\nfunc Run() {}\n");
th_write_file(TH_PATH(base, "config/manager/scratch.go"), "package manager\n\nfunc S() {}\n");
th_write_file(TH_PATH(base, "config/manager/sub/deep.go"), "package sub\n\nfunc Deep() {}\n");
th_write_file(TH_PATH(base, "config/manager/tmp/build.go"), "package tmp\n\nfunc B() {}\n");
th_write_file(TH_PATH(base, "api/types.gen.go"), "package api\n\nfunc Gen() {}\n");
th_write_file(TH_PATH(base, "api/other.gen.go"), "package api\n\nfunc Other() {}\n");
int add_rc = disc_git(base, "add .gitignore main.go");
int force_rc = disc_git(base, "add -f config/manager/manager.go config/manager/sub/deep.go "
"api/types.gen.go");
cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;
int rc = cbm_discover(base, &opts, &files, &count);
bool main_found = rc == 0 && disc_has(files, count, "main.go");
bool manager_found = rc == 0 && disc_has(files, count, "config/manager/manager.go");
bool deep_found = rc == 0 && disc_has(files, count, "config/manager/sub/deep.go");
bool gen_found = rc == 0 && disc_has(files, count, "api/types.gen.go");
bool scratch_found = rc == 0 && disc_has(files, count, "config/manager/scratch.go");
bool build_found = rc == 0 && disc_has(files, count, "config/manager/tmp/build.go");
bool other_found = rc == 0 && disc_has(files, count, "api/other.gen.go");
cbm_discover_free(files, count);
th_cleanup(base);
ASSERT_EQ(add_rc, 0);
ASSERT_EQ(force_rc, 0);
ASSERT_EQ(rc, 0);
ASSERT_TRUE(main_found);
/* Tracked: kept although .gitignore matches them or a directory above. */
ASSERT_TRUE(manager_found);
ASSERT_TRUE(deep_found);
ASSERT_TRUE(gen_found);
/* Untracked and ignored: still out, also below the entered directory. */
ASSERT_FALSE(scratch_found);
ASSERT_FALSE(build_found);
ASSERT_FALSE(other_found);
PASS();
}
TEST(discover_max_file_size) {
char *base = th_mktempdir("cbm_disc_size");
ASSERT(base != NULL);
@@ -2382,6 +2452,7 @@ SUITE(discover) {
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_gitignore_never_hides_tracked_files);
RUN_TEST(discover_max_file_size);
RUN_TEST(discover_null_path);
RUN_TEST(discover_nonexistent_path);