Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 122 additions & 13 deletions src/discover/discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ typedef struct {
int excluded_cap;
} file_list_t;

typedef struct {
cbm_gitignore_t *ignore;
cbm_gitignore_t *negation_overrides;
} cbmignore_matchers_t;

static void file_list_add_excluded(file_list_t *fl, const char *rel_path) {
if (!rel_path || rel_path[0] == '\0') {
return;
Expand Down Expand Up @@ -270,13 +275,114 @@ static const char *local_rel_path(const char *rel_path, const char *local_prefix
return rel_path;
}

static char *read_text_file(const char *path) {
if (!path) {
return NULL;
}

FILE *f = fopen(path, "r");
if (!f) {
return NULL;
}

if (fseek(f, 0, SEEK_END) != 0) {
(void)fclose(f);
return NULL;
}
long size = ftell(f);
if (size < 0 || fseek(f, 0, SEEK_SET) != 0) {
(void)fclose(f);
return NULL;
}

char *buf = malloc((size_t)size + SKIP_ONE);
if (!buf) {
(void)fclose(f);
return NULL;
}

size_t n = fread(buf, SKIP_ONE, (size_t)size, f);
buf[n] = '\0';
(void)fclose(f);
return buf;
}

static cbm_gitignore_t *parse_cbmignore_negation_overrides(const char *content) {
if (!content) {
return NULL;
}

/* Invert pattern polarity so a true match means the last matching rule was negated. */
size_t len = strlen(content);
char *inverted = malloc(len * PAIR_LEN + SKIP_ONE);
if (!inverted) {
return NULL;
}

const char *line = content;
char *out = inverted;
while (*line) {
const char *eol = strchr(line, '\n');
size_t line_len = eol ? (size_t)(eol - line) : strlen(line);

if (line_len == 0 || line[0] == '#') {
memcpy(out, line, line_len);
out += line_len;
} else if (line[0] == '!') {
if (line_len > SKIP_ONE) {
memcpy(out, line + SKIP_ONE, line_len - SKIP_ONE);
out += line_len - SKIP_ONE;
}
} else {
*out++ = '!';
memcpy(out, line, line_len);
out += line_len;
}

if (!eol) {
break;
}
*out++ = '\n';
line = eol + SKIP_ONE;
}
*out = '\0';

cbm_gitignore_t *gi = cbm_gitignore_parse(inverted);
free(inverted);
return gi;
}

static cbmignore_matchers_t load_cbmignore_matchers(const char *path) {
cbmignore_matchers_t matchers = {0};
char *content = read_text_file(path);
if (!content) {
return matchers;
}

matchers.ignore = cbm_gitignore_parse(content);
matchers.negation_overrides = parse_cbmignore_negation_overrides(content);
free(content);
return matchers;
}

static void free_cbmignore_matchers(cbmignore_matchers_t *matchers) {
if (!matchers) {
return;
}
cbm_gitignore_free(matchers->ignore);
cbm_gitignore_free(matchers->negation_overrides);
}

/* 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 *cbmignore, const cbm_gitignore_t *local_gi,
const char *local_gi_prefix) {
const cbm_gitignore_t *cbmignore,
const cbm_gitignore_t *cbmignore_negation_overrides,
const cbm_gitignore_t *local_gi, const char *local_gi_prefix) {
if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL)) {
return true;
if (!cbm_gitignore_matches(cbmignore_negation_overrides, rel_path, true)) {
return true;
}
}
if (gitignore && cbm_gitignore_matches(gitignore, rel_path, true)) {
return true;
Expand Down Expand Up @@ -435,8 +541,9 @@ 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 *cbmignore, walk_frame_t *stack, int *top,
file_list_t *out) {
const cbm_gitignore_t *cbmignore,
const cbm_gitignore_t *cbmignore_negation_overrides,
walk_frame_t *stack, int *top, file_list_t *out) {
char abs_path[CBM_SZ_4K];
char rel_path[CBM_SZ_4K];
snprintf(abs_path, sizeof(abs_path), "%s/%s", frame->dir, entry->name);
Expand All @@ -453,7 +560,8 @@ 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,
frame->local_gi, frame->local_gi_prefix)) {
cbmignore_negation_overrides, frame->local_gi,
frame->local_gi_prefix)) {
walk_push_subdir(stack, top, abs_path, rel_path, frame);
} else {
/* Record the excluded subtree root so callers can report it (#411). */
Expand All @@ -469,7 +577,7 @@ 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 *cbmignore_negation_overrides, file_list_t *out) {
walk_frame_t *stack = calloc(WALK_STACK_CAP, sizeof(walk_frame_t));
if (!stack) {
return;
Expand Down Expand Up @@ -503,7 +611,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, cbmignore,
cbmignore_negation_overrides, stack, &top, out);
}
cbm_closedir(d);
}
Expand Down Expand Up @@ -552,21 +661,21 @@ int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_
}

/* Load cbmignore if specified or exists at repo root */
cbm_gitignore_t *cbmignore = NULL;
cbmignore_matchers_t cbmignore = {0};
if (opts && opts->ignore_file) {
cbmignore = cbm_gitignore_load(opts->ignore_file);
cbmignore = load_cbmignore_matchers(opts->ignore_file);
} else {
snprintf(gi_path, sizeof(gi_path), "%s/.cbmignore", repo_path);
cbmignore = cbm_gitignore_load(gi_path);
cbmignore = load_cbmignore_matchers(gi_path);
}

/* Walk */
file_list_t fl = {0};
walk_dir(repo_path, "", opts, gitignore, cbmignore, &fl);
walk_dir(repo_path, "", opts, gitignore, cbmignore.ignore, cbmignore.negation_overrides, &fl);

/* Cleanup */
cbm_gitignore_free(gitignore);
cbm_gitignore_free(cbmignore);
free_cbmignore_matchers(&cbmignore);

*out = fl.files;
*count = fl.count;
Expand Down
132 changes: 132 additions & 0 deletions tests/test_discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
#include "test_helpers.h"
#include "discover/discover.h"

static bool discovered_rel_path(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) {
Expand Down Expand Up @@ -627,6 +636,124 @@ TEST(discover_cbmignore_no_git) {
PASS();
}

TEST(discover_cbmignore_negates_always_skip_dir) {
char *base = th_mktempdir("cbm_disc_cbmi_neg_obj");
ASSERT(base != NULL);

th_write_file(TH_PATH(base, ".cbmignore"), "!obj/\n");
th_write_file(TH_PATH(base, "main.go"), "package main\n");
th_write_file(TH_PATH(base, "obj/generated.go"), "package obj\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;

int rc = cbm_discover(base, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 2);
ASSERT_TRUE(discovered_rel_path(files, count, "main.go"));
ASSERT_TRUE(discovered_rel_path(files, count, "obj/generated.go"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

TEST(discover_always_skip_dir_without_negation) {
char *base = th_mktempdir("cbm_disc_obj_default");
ASSERT(base != NULL);

th_write_file(TH_PATH(base, "main.go"), "package main\n");
th_write_file(TH_PATH(base, "obj/generated.go"), "package obj\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;

int rc = cbm_discover(base, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(discovered_rel_path(files, count, "main.go"));
ASSERT_FALSE(discovered_rel_path(files, count, "obj/generated.go"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

TEST(discover_cbmignore_negates_only_nested_skip_dir) {
char *base = th_mktempdir("cbm_disc_cbmi_neg_nested");
ASSERT(base != NULL);

th_write_file(TH_PATH(base, ".cbmignore"), "!src/target/\n");
th_write_file(TH_PATH(base, "src/main.go"), "package src\n");
th_write_file(TH_PATH(base, "src/target/lib.go"), "package target\n");
th_write_file(TH_PATH(base, "other/target/lib.go"), "package other\n");
th_write_file(TH_PATH(base, "target/root.go"), "package root\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;

int rc = cbm_discover(base, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 2);
ASSERT_TRUE(discovered_rel_path(files, count, "src/main.go"));
ASSERT_TRUE(discovered_rel_path(files, count, "src/target/lib.go"));
ASSERT_FALSE(discovered_rel_path(files, count, "other/target/lib.go"));
ASSERT_FALSE(discovered_rel_path(files, count, "target/root.go"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

TEST(discover_cbmignore_positive_dir_does_not_unskip_builtin) {
char *base = th_mktempdir("cbm_disc_cbmi_pos_obj");
ASSERT(base != NULL);

th_write_file(TH_PATH(base, ".cbmignore"), "obj/\n");
th_write_file(TH_PATH(base, "main.go"), "package main\n");
th_write_file(TH_PATH(base, "obj/generated.go"), "package obj\n");

cbm_discover_opts_t opts = {0};
cbm_file_info_t *files = NULL;
int count = 0;

int rc = cbm_discover(base, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 1);
ASSERT_TRUE(discovered_rel_path(files, count, "main.go"));
ASSERT_FALSE(discovered_rel_path(files, count, "obj/generated.go"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

TEST(discover_cbmignore_negates_fast_skip_dir) {
char *base = th_mktempdir("cbm_disc_cbmi_neg_fast");
ASSERT(base != NULL);

th_write_file(TH_PATH(base, ".cbmignore"), "!docs/\n");
th_write_file(TH_PATH(base, "main.go"), "package main\n");
th_write_file(TH_PATH(base, "docs/guide.go"), "package docs\n");

cbm_discover_opts_t opts = {.mode = CBM_MODE_FAST};
cbm_file_info_t *files = NULL;
int count = 0;

int rc = cbm_discover(base, &opts, &files, &count);
ASSERT_EQ(rc, 0);
ASSERT_EQ(count, 2);
ASSERT_TRUE(discovered_rel_path(files, count, "main.go"));
ASSERT_TRUE(discovered_rel_path(files, count, "docs/guide.go"));

cbm_discover_free(files, count);
th_cleanup(base);
PASS();
}

/* ── Nested .gitignore tests (issue #178) ──────────────────────── */

TEST(discover_nested_gitignore) {
Expand Down Expand Up @@ -793,6 +920,11 @@ SUITE(discover) {
RUN_TEST(discover_generic_dirs_full_mode);
RUN_TEST(discover_generic_dirs_fast_mode);
RUN_TEST(discover_cbmignore_no_git);
RUN_TEST(discover_cbmignore_negates_always_skip_dir);
RUN_TEST(discover_always_skip_dir_without_negation);
RUN_TEST(discover_cbmignore_negates_only_nested_skip_dir);
RUN_TEST(discover_cbmignore_positive_dir_does_not_unskip_builtin);
RUN_TEST(discover_cbmignore_negates_fast_skip_dir);

/* Nested .gitignore tests (issue #178) */
RUN_TEST(discover_nested_gitignore);
Expand Down
Loading