Skip to content

Commit 6b22165

Browse files
fix(discover): honor .git/info/exclude during file discovery
index_repository only loaded .gitignore when building the exclusion matcher. .git/info/exclude — the per-clone exclude file that Git treats as authoritative — was never read, so paths excluded only there were walked in full. On repos with Sandcastle worktrees this caused the indexer to traverse 661K files / 170 GB and OOM (issue #489). Add cbm_gitignore_merge() to append patterns from one matcher into another. In cbm_discover_ex, after loading .gitignore, also load .git/info/exclude and merge its patterns in. If only the exclude file exists, use it directly. No downstream call paths change. Add three unit tests for cbm_gitignore_merge and two integration tests that reproduce the issue scenario (exclude-only and exclude-stacked- with-gitignore). Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
1 parent e599df1 commit 6b22165

5 files changed

Lines changed: 161 additions & 1 deletion

File tree

src/discover/discover.c

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,14 +541,32 @@ int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_
541541
return CBM_NOT_FOUND;
542542
}
543543

544-
/* Load gitignore if .git directory exists */
544+
/* Load gitignore sources when a .git directory is present.
545+
* Sources merged in order (later patterns win on conflict):
546+
* 1. <repo>/.gitignore — committed exclusions
547+
* 2. <repo>/.git/info/exclude — per-clone exclusions, not committed
548+
* Both are folded into a single matcher so all downstream call paths
549+
* remain unchanged. Fixes issue #489: OOM on repos whose worktrees
550+
* are excluded only via .git/info/exclude (e.g. Sandcastle). */
545551
cbm_gitignore_t *gitignore = NULL;
546552
char gi_path[CBM_SZ_4K];
547553
snprintf(gi_path, sizeof(gi_path), "%s/.git", repo_path);
548554
struct stat gi_stat;
549555
if (wide_stat(gi_path, &gi_stat) == 0 && S_ISDIR(gi_stat.st_mode)) {
550556
snprintf(gi_path, sizeof(gi_path), "%s/.gitignore", repo_path);
551557
gitignore = cbm_gitignore_load(gi_path);
558+
559+
char exc_path[CBM_SZ_4K];
560+
snprintf(exc_path, sizeof(exc_path), "%s/.git/info/exclude", repo_path);
561+
cbm_gitignore_t *git_exclude = cbm_gitignore_load(exc_path);
562+
if (git_exclude) {
563+
if (!gitignore) {
564+
gitignore = git_exclude;
565+
} else {
566+
cbm_gitignore_merge(gitignore, git_exclude);
567+
cbm_gitignore_free(git_exclude);
568+
}
569+
}
552570
}
553571

554572
/* Load cbmignore if specified or exists at repo root */

src/discover/discover.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool
5858
/* Free a gitignore matcher. NULL-safe. */
5959
void cbm_gitignore_free(cbm_gitignore_t *gi);
6060

61+
/* Append all patterns from src into dst. dst takes ownership of deep copies
62+
* of each src pattern; src is unchanged and must still be freed by the caller.
63+
* NULL-safe on either argument. */
64+
void cbm_gitignore_merge(cbm_gitignore_t *dst, const cbm_gitignore_t *src);
65+
6166
/* ── Directory skip / suffix filters ─────────────────────────────── */
6267

6368
/* Index mode controls filtering aggressiveness.

src/discover/gitignore.c

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,29 @@ void cbm_gitignore_free(cbm_gitignore_t *gi) {
379379
free(gi->patterns);
380380
free(gi);
381381
}
382+
383+
void cbm_gitignore_merge(cbm_gitignore_t *dst, const cbm_gitignore_t *src) {
384+
if (!dst || !src || src->count == 0) {
385+
return;
386+
}
387+
int needed = dst->count + src->count;
388+
if (needed > dst->capacity) {
389+
gi_pattern_t *grown = realloc(dst->patterns, (size_t)needed * sizeof(gi_pattern_t));
390+
if (!grown) {
391+
return;
392+
}
393+
dst->patterns = grown;
394+
dst->capacity = needed;
395+
}
396+
for (int i = 0; i < src->count; i++) {
397+
char *pat = strdup(src->patterns[i].pattern);
398+
if (!pat) {
399+
return;
400+
}
401+
dst->patterns[dst->count].pattern = pat;
402+
dst->patterns[dst->count].negated = src->patterns[i].negated;
403+
dst->patterns[dst->count].dir_only = src->patterns[i].dir_only;
404+
dst->patterns[dst->count].rooted = src->patterns[i].rooted;
405+
dst->count++;
406+
}
407+
}

tests/test_discover.c

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,66 @@ TEST(discover_cbmignore_no_git) {
627627
PASS();
628628
}
629629

630+
/* ── .git/info/exclude tests (issue #489) ─────────────────────── */
631+
632+
/* Per-clone excludes written to .git/info/exclude (not committed) must be
633+
* honored the same as .gitignore. Without this, repos that keep worktrees
634+
* under a path excluded only via info/exclude hit OOM during indexing. */
635+
TEST(discover_git_info_exclude) {
636+
char *base = th_mktempdir("cbm_disc_exc");
637+
ASSERT(base != NULL);
638+
639+
th_mkdir_p(TH_PATH(base, ".git/info"));
640+
th_write_file(TH_PATH(base, ".git/info/exclude"), "worktrees/\n");
641+
th_write_file(TH_PATH(base, "src/main.go"), "package main\n");
642+
th_write_file(TH_PATH(base, "worktrees/feature/app.go"), "package app\n");
643+
644+
cbm_discover_opts_t opts = {0};
645+
cbm_file_info_t *files = NULL;
646+
int count = 0;
647+
int rc = cbm_discover(base, &opts, &files, &count);
648+
ASSERT_EQ(rc, 0);
649+
ASSERT_EQ(count, 1);
650+
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
651+
652+
cbm_discover_free(files, count);
653+
th_cleanup(base);
654+
PASS();
655+
}
656+
657+
TEST(discover_git_info_exclude_stacks_with_gitignore) {
658+
char *base = th_mktempdir("cbm_disc_exc_stack");
659+
ASSERT(base != NULL);
660+
661+
th_mkdir_p(TH_PATH(base, ".git/info"));
662+
th_write_file(TH_PATH(base, ".gitignore"), "*.log\n");
663+
th_write_file(TH_PATH(base, ".git/info/exclude"), "scratch/\n");
664+
th_write_file(TH_PATH(base, "main.go"), "package main\n");
665+
th_write_file(TH_PATH(base, "debug.log"), "log\n");
666+
th_write_file(TH_PATH(base, "scratch/tmp.go"), "package scratch\n");
667+
668+
cbm_discover_opts_t opts = {0};
669+
cbm_file_info_t *files = NULL;
670+
int count = 0;
671+
int rc = cbm_discover(base, &opts, &files, &count);
672+
ASSERT_EQ(rc, 0);
673+
ASSERT_EQ(count, 1);
674+
675+
bool found_log = false;
676+
bool found_scratch = false;
677+
for (int i = 0; i < count; i++) {
678+
if (strstr(files[i].rel_path, ".log")) found_log = true;
679+
if (strstr(files[i].rel_path, "scratch")) found_scratch = true;
680+
}
681+
ASSERT_FALSE(found_log);
682+
ASSERT_FALSE(found_scratch);
683+
ASSERT_TRUE(strstr(files[0].rel_path, "main.go") != NULL);
684+
685+
cbm_discover_free(files, count);
686+
th_cleanup(base);
687+
PASS();
688+
}
689+
630690
/* ── Nested .gitignore tests (issue #178) ──────────────────────── */
631691

632692
TEST(discover_nested_gitignore) {
@@ -794,6 +854,10 @@ SUITE(discover) {
794854
RUN_TEST(discover_generic_dirs_fast_mode);
795855
RUN_TEST(discover_cbmignore_no_git);
796856

857+
/* .git/info/exclude support (issue #489) */
858+
RUN_TEST(discover_git_info_exclude);
859+
RUN_TEST(discover_git_info_exclude_stacks_with_gitignore);
860+
797861
/* Nested .gitignore tests (issue #178) */
798862
RUN_TEST(discover_nested_gitignore);
799863
RUN_TEST(discover_nested_gitignore_stacks_with_root);

tests/test_gitignore.c

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,50 @@ TEST(gi_load_nonexistent) {
186186
PASS();
187187
}
188188

189+
/* ── Merge ─────────────────────────────────────────────────────── */
190+
191+
TEST(gi_merge_patterns) {
192+
cbm_gitignore_t *base_gi = cbm_gitignore_parse("*.log\n");
193+
cbm_gitignore_t *extra = cbm_gitignore_parse("tmp/\nbuild/\n");
194+
ASSERT_NOT_NULL(base_gi);
195+
ASSERT_NOT_NULL(extra);
196+
197+
cbm_gitignore_merge(base_gi, extra);
198+
cbm_gitignore_free(extra);
199+
200+
ASSERT_TRUE(cbm_gitignore_matches(base_gi, "error.log", false));
201+
ASSERT_TRUE(cbm_gitignore_matches(base_gi, "tmp", true));
202+
ASSERT_TRUE(cbm_gitignore_matches(base_gi, "build", true));
203+
ASSERT_FALSE(cbm_gitignore_matches(base_gi, "main.go", false));
204+
205+
cbm_gitignore_free(base_gi);
206+
PASS();
207+
}
208+
209+
TEST(gi_merge_into_empty) {
210+
cbm_gitignore_t *dst = cbm_gitignore_parse("");
211+
cbm_gitignore_t *src = cbm_gitignore_parse("*.o\n");
212+
ASSERT_NOT_NULL(dst);
213+
ASSERT_NOT_NULL(src);
214+
215+
cbm_gitignore_merge(dst, src);
216+
cbm_gitignore_free(src);
217+
218+
ASSERT_TRUE(cbm_gitignore_matches(dst, "main.o", false));
219+
ASSERT_FALSE(cbm_gitignore_matches(dst, "main.c", false));
220+
221+
cbm_gitignore_free(dst);
222+
PASS();
223+
}
224+
225+
TEST(gi_merge_null_safe) {
226+
cbm_gitignore_t *gi = cbm_gitignore_parse("*.log\n");
227+
cbm_gitignore_merge(gi, NULL); /* should not crash */
228+
cbm_gitignore_merge(NULL, gi); /* should not crash */
229+
cbm_gitignore_free(gi);
230+
PASS();
231+
}
232+
189233
/* ── Suite ─────────────────────────────────────────────────────── */
190234

191235
SUITE(gitignore) {
@@ -206,4 +250,7 @@ SUITE(gitignore) {
206250
RUN_TEST(gi_null_safe_free);
207251
RUN_TEST(gi_load_file);
208252
RUN_TEST(gi_load_nonexistent);
253+
RUN_TEST(gi_merge_patterns);
254+
RUN_TEST(gi_merge_into_empty);
255+
RUN_TEST(gi_merge_null_safe);
209256
}

0 commit comments

Comments
 (0)