Skip to content

Commit 9291014

Browse files
authored
Merge pull request #2181 from AmirF194/fix/510-enclosing-gitignore-not-inherited
fix(discover): honor an enclosing repo's .gitignore for a git-less subfolder
2 parents 69fb951 + 82a6ddf commit 9291014

2 files changed

Lines changed: 431 additions & 17 deletions

File tree

‎src/discover/discover.c‎

Lines changed: 216 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -596,26 +596,40 @@ static const char *local_rel_path(const char *rel_path, const char *local_prefix
596596
* matcher borrows a pointer to the deepest link governing it, so a directory
597597
* without a .gitignore of its own simply shares its parent's link. Links live
598598
* on the heap (the frame stack is realloc'd and popped) and walk_dir owns them
599-
* through the `owned_next` list. `prefix` is the walk-relative directory the
600-
* matcher was loaded from ("" for the root). */
599+
* through the `owned_next` list.
600+
*
601+
* Every pattern is anchored to the directory its file came from, so each link
602+
* carries the offset between that directory and the walk-relative rel_path it
603+
* is asked about. The two offsets are mirror images and exactly one is ever
604+
* non-empty:
605+
* `prefix` — the matcher's directory is at or BELOW the walk root
606+
* ("" for the root itself): strip it from rel_path.
607+
* `base` — the matcher's directory is ABOVE the walk root (an enclosing
608+
* repository's ignore files, when a git-less subfolder is
609+
* indexed): prepend the walk root's path relative to it.
610+
* Both strings live in the flexible tail, `base` right after `prefix`. */
601611
typedef struct gitignore_link {
602612
const cbm_gitignore_t *gi;
603613
const struct gitignore_link *parent;
604614
struct gitignore_link *owned_next;
615+
const char *base;
605616
char prefix[];
606617
} gitignore_link_t;
607618

608619
static gitignore_link_t *gitignore_link_new(const cbm_gitignore_t *gi, const char *prefix,
609-
const gitignore_link_t *parent,
620+
const char *base, const gitignore_link_t *parent,
610621
gitignore_link_t **owned_links) {
611622
size_t prefix_size = strlen(prefix) + SKIP_ONE;
612-
gitignore_link_t *link = malloc(sizeof(*link) + prefix_size);
623+
size_t base_size = strlen(base) + SKIP_ONE;
624+
gitignore_link_t *link = malloc(sizeof(*link) + prefix_size + base_size);
613625
if (!link) {
614626
return NULL;
615627
}
616628
link->gi = gi;
617629
link->parent = parent;
618630
memcpy(link->prefix, prefix, prefix_size);
631+
memcpy(link->prefix + prefix_size, base, base_size);
632+
link->base = link->prefix + prefix_size;
619633
link->owned_next = *owned_links;
620634
*owned_links = link;
621635
return link;
@@ -636,9 +650,20 @@ static void gitignore_links_free(gitignore_link_t *link) {
636650
* re-included by a negation, 0 when no file mentions the path. Cost is one
637651
* match per .gitignore on the path — O(depth), never a rescan. */
638652
static int gitignore_chain_result(const gitignore_link_t *link, const char *rel_path, bool is_dir) {
653+
char based[CBM_SZ_4K];
639654
for (; link; link = link->parent) {
640-
int verdict =
641-
cbm_gitignore_match_result(link->gi, local_rel_path(rel_path, link->prefix), is_dir);
655+
const char *probe = local_rel_path(rel_path, link->prefix);
656+
if (link->base[0]) {
657+
/* Matcher from a directory above the walk root: ask it about the
658+
* path it would see, i.e. the one its own rooted patterns are
659+
* anchored against. */
660+
int written = snprintf(based, sizeof(based), "%s/%s", link->base, probe);
661+
if (written < 0 || (size_t)written >= sizeof(based)) {
662+
continue; /* longer than this matcher can address: no opinion */
663+
}
664+
probe = based;
665+
}
666+
int verdict = cbm_gitignore_match_result(link->gi, probe, is_dir);
642667
if (verdict != 0) {
643668
return verdict;
644669
}
@@ -1034,9 +1059,20 @@ static bool walk_owned_gitignore_append(cbm_gitignore_t ***owned, size_t *count,
10341059
return true;
10351060
}
10361061

1062+
/* Release an owner array built by walk_owned_gitignore_append(). Shared by the
1063+
* walk and by the enclosing-repository chain, which own their matchers the
1064+
* same way. */
1065+
static void walk_owned_gitignore_free(cbm_gitignore_t **owned, size_t count) {
1066+
for (size_t i = 0; i < count; i++) {
1067+
cbm_gitignore_free(owned[i]);
1068+
}
1069+
free(owned);
1070+
}
1071+
10371072
static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts,
1038-
const cbm_gitignore_t *gitignore, const cbm_gitignore_t *global_gi,
1039-
const cbm_gitignore_t *cbmignore, file_list_t *out) {
1073+
const cbm_gitignore_t *gitignore, const gitignore_link_t *ancestors,
1074+
const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore,
1075+
file_list_t *out) {
10401076
walk_stack_t ws = {
10411077
.frames = calloc(WALK_STACK_CAP, sizeof(walk_frame_t)), .top = 0, .cap = WALK_STACK_CAP};
10421078
if (!ws.frames) {
@@ -1058,8 +1094,12 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis
10581094
free(ws.frames);
10591095
return;
10601096
}
1097+
/* `ancestors` is the deepest link of the enclosing repository's chain (or
1098+
* NULL); the walk root's own matcher sits below it so it still wins. */
1099+
ws.frames[0].ignore_chain = ancestors;
10611100
if (gitignore) {
1062-
ws.frames[0].ignore_chain = gitignore_link_new(gitignore, rel_prefix, NULL, &owned_links);
1101+
ws.frames[0].ignore_chain =
1102+
gitignore_link_new(gitignore, rel_prefix, "", ancestors, &owned_links);
10631103
if (!ws.frames[0].ignore_chain) {
10641104
out->failed = true;
10651105
free(ws.frames);
@@ -1080,7 +1120,7 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis
10801120
}
10811121
/* owned_gis owns `loaded` from here on, even if the link fails. */
10821122
const gitignore_link_t *link =
1083-
gitignore_link_new(loaded, frame.prefix, frame.ignore_chain, &owned_links);
1123+
gitignore_link_new(loaded, frame.prefix, "", frame.ignore_chain, &owned_links);
10841124
if (!link) {
10851125
out->failed = true;
10861126
break;
@@ -1102,10 +1142,7 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis
11021142
}
11031143
cbm_closedir(d);
11041144
}
1105-
for (size_t i = 0; i < owned_count; i++) {
1106-
cbm_gitignore_free(owned_gis[i]);
1107-
}
1108-
free(owned_gis);
1145+
walk_owned_gitignore_free(owned_gis, owned_count);
11091146
gitignore_links_free(owned_links);
11101147
free(ws.frames);
11111148
}
@@ -1214,6 +1251,133 @@ static bool resolve_git_common_dir(const char *repo_path, char *common_dir, size
12141251
return true;
12151252
}
12161253

1254+
/* When repo_path itself carries no .git (resolve_git_common_dir already
1255+
* returned false for it), walk upward looking for an enclosing repository,
1256+
* exactly as `git` itself would when run from a subfolder. Bounded by the
1257+
* filesystem/drive root: dir is truncated at each iteration, so the loop
1258+
* cannot run more times than repo_path is characters long. Returns true and
1259+
* fills ancestor_root (for the enclosing repo's own .gitignore) plus
1260+
* common_dir (for its info/exclude + config, via the same resolution
1261+
* resolve_git_common_dir already applies to an ordinary repo root). Fixes
1262+
* the remaining half of issue #510: only the indexed directory's own
1263+
* .gitignore was ever consulted, never an enclosing repo's. */
1264+
static bool resolve_enclosing_git_root(const char *repo_path, char *ancestor_root, size_t ar_sz,
1265+
char *common_dir, size_t cd_sz) {
1266+
char dir[CBM_SZ_4K];
1267+
snprintf(dir, sizeof(dir), "%s", repo_path);
1268+
cbm_normalize_path_sep(dir);
1269+
1270+
for (;;) {
1271+
char *slash = strrchr(dir, '/');
1272+
/* No separator left, or only the root separator (POSIX "/") or a
1273+
* bare drive prefix (Windows "C:/"): nothing above this to check. */
1274+
if (!slash || slash == dir || (slash > dir && *(slash - 1) == ':')) {
1275+
return false;
1276+
}
1277+
*slash = '\0';
1278+
if (resolve_git_common_dir(dir, common_dir, cd_sz)) {
1279+
snprintf(ancestor_root, ar_sz, "%s", dir);
1280+
return true;
1281+
}
1282+
}
1283+
}
1284+
1285+
/* The enclosing repository's ignore files, kept OUT of the indexed directory's
1286+
* own matcher. Merging them in would re-anchor every rooted pattern onto the
1287+
* indexed subfolder: "/secret.py" at the enclosing root would start hiding
1288+
* <subfolder>/secret.py (silent index loss), and "pkg/scratch/" would stop
1289+
* hiding pkg/scratch (a directory git excludes would be walked). Each matcher
1290+
* instead keeps its own directory as its anchor, via the link's `base`. */
1291+
typedef struct {
1292+
cbm_gitignore_t **owned;
1293+
size_t count;
1294+
size_t capacity;
1295+
const gitignore_link_t *chain; /* deepest ancestor link, or NULL */
1296+
gitignore_link_t *links; /* owner list backing `chain` */
1297+
} ancestor_ignores_t;
1298+
1299+
static void ancestor_ignores_free(ancestor_ignores_t *anc) {
1300+
walk_owned_gitignore_free(anc->owned, anc->count);
1301+
gitignore_links_free(anc->links);
1302+
}
1303+
1304+
/* Add one ancestor directory's matcher to the chain, deepest last so that the
1305+
* deepest file with an opinion wins (git's shallow-to-deep rule, negations
1306+
* included). Takes ownership of `gi`. */
1307+
static bool ancestor_ignores_push(ancestor_ignores_t *anc, cbm_gitignore_t *gi, const char *base) {
1308+
if (!walk_owned_gitignore_append(&anc->owned, &anc->count, &anc->capacity, gi)) {
1309+
cbm_gitignore_free(gi);
1310+
return false;
1311+
}
1312+
/* owned[] owns `gi` from here on, even if the link fails. */
1313+
const gitignore_link_t *link = gitignore_link_new(gi, "", base, anc->chain, &anc->links);
1314+
if (!link) {
1315+
return false;
1316+
}
1317+
anc->chain = link;
1318+
return true;
1319+
}
1320+
1321+
/* Load the .gitignore of EVERY directory from the enclosing repository root
1322+
* (inclusive) down to the indexed directory (exclusive) — git consults all of
1323+
* them, not just the root's. `root_extra` is that repository's
1324+
* <common>/info/exclude, which git anchors at its root like the root
1325+
* .gitignore; ownership is taken. `leaf` must be a canonical path under
1326+
* `root`, both with '/' separators. Returns false only on allocation
1327+
* failure. */
1328+
static bool ancestor_ignores_build(ancestor_ignores_t *anc, const char *root, const char *leaf,
1329+
cbm_gitignore_t *root_extra) {
1330+
size_t root_len = strlen(root);
1331+
if (strncmp(leaf, root, root_len) != 0) {
1332+
cbm_gitignore_free(root_extra);
1333+
return true;
1334+
}
1335+
const char *base = leaf + root_len;
1336+
while (*base == '/') {
1337+
base++;
1338+
}
1339+
1340+
char dir[CBM_SZ_4K];
1341+
int dir_len = snprintf(dir, sizeof(dir), "%s", root);
1342+
if (dir_len < 0 || (size_t)dir_len >= sizeof(dir) || !*base) {
1343+
cbm_gitignore_free(root_extra);
1344+
return dir_len >= 0 && (size_t)dir_len < sizeof(dir);
1345+
}
1346+
1347+
for (;;) {
1348+
char gi_path[CBM_SZ_4K];
1349+
path_join(gi_path, sizeof(gi_path), dir, ".gitignore");
1350+
cbm_gitignore_t *gi = cbm_gitignore_load(gi_path);
1351+
if (root_extra) {
1352+
/* First iteration: `dir` is the enclosing repository root, the one
1353+
* directory info/exclude shares an anchor with. Merged after the
1354+
* .gitignore patterns so it overrides them, the same precedence
1355+
* the two already have for an ordinary repository root. */
1356+
if (!gi) {
1357+
gi = root_extra;
1358+
} else {
1359+
(void)cbm_gitignore_merge(gi, root_extra);
1360+
cbm_gitignore_free(root_extra);
1361+
}
1362+
root_extra = NULL;
1363+
}
1364+
if (gi && !ancestor_ignores_push(anc, gi, base)) {
1365+
return false;
1366+
}
1367+
const char *slash = strchr(base, '/');
1368+
if (!slash) {
1369+
return true; /* the next directory down IS the indexed one */
1370+
}
1371+
int written = snprintf(dir + dir_len, sizeof(dir) - (size_t)dir_len, "/%.*s",
1372+
(int)(slash - base), base);
1373+
if (written < 0 || (size_t)written >= sizeof(dir) - (size_t)dir_len) {
1374+
return true; /* deeper directories are unaddressable: stop here */
1375+
}
1376+
dir_len += written;
1377+
base = slash + SKIP_ONE;
1378+
}
1379+
}
1380+
12171381
int cbm_discover(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
12181382
int *count) {
12191383
return cbm_discover_ex(repo_path, opts, out, count, NULL, NULL);
@@ -1270,7 +1434,12 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
12701434
* worktree (where .git is a gitlink file) reads the shared info/exclude/config
12711435
* just like a normal checkout. Both are folded into a single matcher so all
12721436
* downstream call paths remain unchanged. Fixes issue #489: OOM on repos whose
1273-
* worktrees are excluded only via .git/info/exclude (e.g. Sandcastle). */
1437+
* worktrees are excluded only via .git/info/exclude (e.g. Sandcastle).
1438+
*
1439+
* An ENCLOSING repository's ignore files (indexing a git-less subfolder,
1440+
* issue #510) are deliberately NOT folded in here: their patterns are
1441+
* anchored to their own directories, so they stay separate matchers on the
1442+
* chain with a `base` offset. See ancestor_ignores_build(). */
12741443
cbm_gitignore_t *gitignore = NULL;
12751444
char gi_path[CBM_SZ_4K];
12761445
struct stat gi_stat;
@@ -1280,6 +1449,26 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
12801449
char git_common_dir[CBM_SZ_4K];
12811450
bool is_git_repo = resolve_git_common_dir(repo_path, git_common_dir, sizeof(git_common_dir));
12821451
bool has_git_config = false;
1452+
/* repo_path itself is not a git repo root: walk upward for an enclosing one,
1453+
* exactly as `git` does when run from a subfolder. Fixes the remaining half
1454+
* of issue #510: only the indexed directory's own .gitignore was ever
1455+
* consulted, never an enclosing repo's. The walk is lexical, so repo_path is
1456+
* canonicalized first — otherwise `cbm index pkg` and `cbm index /abs/pkg`
1457+
* would discover different files from the same directory. */
1458+
ancestor_ignores_t ancestors = {0};
1459+
char enclosing_root[CBM_SZ_4K];
1460+
char canonical_repo[CBM_SZ_4K];
1461+
bool has_enclosing_repo = false;
1462+
if (!is_git_repo) {
1463+
if (!cbm_canonical_path(repo_path, canonical_repo, sizeof(canonical_repo))) {
1464+
snprintf(canonical_repo, sizeof(canonical_repo), "%s", repo_path);
1465+
}
1466+
cbm_normalize_path_sep(canonical_repo);
1467+
has_enclosing_repo =
1468+
resolve_enclosing_git_root(canonical_repo, enclosing_root, sizeof(enclosing_root),
1469+
git_common_dir, sizeof(git_common_dir));
1470+
is_git_repo = has_enclosing_repo;
1471+
}
12831472
/* Always honour the .gitignore at the indexed-directory root, even when the
12841473
* directory is not a git repo root (e.g. indexing a sub-package directly).
12851474
* Fixes issue #510: a root .gitignore was silently ignored without .git/. */
@@ -1292,7 +1481,16 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
12921481
char exc_path[CBM_SZ_4K];
12931482
path_join(exc_path, sizeof(exc_path), git_common_dir, "info/exclude");
12941483
cbm_gitignore_t *git_exclude = cbm_gitignore_load(exc_path);
1295-
if (git_exclude) {
1484+
if (has_enclosing_repo) {
1485+
/* git_common_dir belongs to the ENCLOSING repository, so its
1486+
* info/exclude is anchored at that repository's root, not at the
1487+
* indexed subfolder. Ownership passes to the ancestor chain. */
1488+
if (!ancestor_ignores_build(&ancestors, enclosing_root, canonical_repo, git_exclude)) {
1489+
ancestor_ignores_free(&ancestors);
1490+
cbm_gitignore_free(gitignore);
1491+
return CBM_DISCOVER_ERROR;
1492+
}
1493+
} else if (git_exclude) {
12961494
if (!gitignore) {
12971495
gitignore = git_exclude;
12981496
} else {
@@ -1330,9 +1528,10 @@ static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_disc
13301528
.collect_ignored = !count_only && ignored_out != NULL,
13311529
};
13321530
walk_cache_dir_snapshot();
1333-
walk_dir(repo_path, "", opts, gitignore, global_gi, cbmignore, &fl);
1531+
walk_dir(repo_path, "", opts, gitignore, ancestors.chain, global_gi, cbmignore, &fl);
13341532

13351533
/* Cleanup */
1534+
ancestor_ignores_free(&ancestors);
13361535
cbm_gitignore_free(gitignore);
13371536
cbm_gitignore_free(global_gi);
13381537
cbm_gitignore_free(cbmignore);

0 commit comments

Comments
 (0)