@@ -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`. */
601611typedef 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
608619static 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. */
638652static 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+
10371072static 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+
12171381int 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