{"thread":{"id":"66328","subject":"[BUG] 'git status --ignored' <pathspec> matches partial names","startedAt":"2026-09-14T13:31:42Z","lastAt":"2026-09-26T10:51:52Z","messageCount":6,"participants":["Sean Whitton","René Scharfe","Junio C Hamano"],"isPatch":false,"patchVersion":null,"patchTotal":null},"messages":[{"id":"552707","messageId":"87ld94klhf.fsf@athena.silentflame.com","threadId":"66328","inReplyTo":null,"subject":"[BUG] 'git status --ignored' <pathspec> matches partial names","fromName":"Sean Whitton","fromEmail":"spwhitton@spwhitton.name","sentAt":"2026-09-14T13:31:40Z","receivedAt":"2026-09-14T13:31:42Z","isPatch":false,"body":"Steps to reproduce:\n\ngit init foo\ncd foo\nmkdir bar\necho bar/ >.gitignore\ngit init bar/baz\ngit init bar/quux\ngit status --porcelain --ignored --untracked-files -- ba\n\nExpected output:\nnone, \"ba\" doesn't match \"bar\".\n\nActual output:\n!! bar/baz/\n!! bar/quux/\n\nCredits to Lester Longley for the reproduction, in Emacs bug#81625.\n\n-- \nSean Whitton\n"},{"id":"552758","messageId":"ff9404bc-63e1-43bc-8419-9685f0bfa32d@web.de","threadId":"66328","inReplyTo":"87ld94klhf.fsf@athena.silentflame.com","subject":"Re: [BUG] 'git status --ignored' <pathspec> matches partial names","fromName":"René Scharfe","fromEmail":"l.s.r@web.de","sentAt":"2026-09-15T15:54:36Z","receivedAt":"2026-09-15T15:54:57Z","isPatch":false,"body":"On 9/14/26 3:31 PM, Sean Whitton wrote:\n> Steps to reproduce:\n> \n> git init foo\n> cd foo\n> mkdir bar\n> echo bar/ >.gitignore\n> git init bar/baz\n> git init bar/quux\n> git status --porcelain --ignored --untracked-files -- ba\n> \n> Expected output:\n> none, \"ba\" doesn't match \"bar\".\n> \n> Actual output:\n> !! bar/baz/\n> !! bar/quux/\n> \n> Credits to Lester Longley for the reproduction, in Emacs bug#81625.\n> \n\nBisects to 95c11ecc73 (Fix error-prone fill_directory() API; make it\nonly return matches, 2020-04-01).\n\nI can reproduce the issue using the instructions above, but not in Git's\nown repo like this:\n\n   $ git status --porcelain --ignored --untracked-files -- .depend | grep -c '^!!'\n   266\n   $ git status --porcelain --ignored --untracked-files -- .depen | grep -c '^!!'\n   0\n\nBelow is a quick fix, but perhaps this optimization can be repaired\ninstead of removed.\n\nRené\n\n\ndiff --git a/dir.c b/dir.c\nindex 95d8a1cce9..3718301a89 100644\n--- a/dir.c\n+++ b/dir.c\n@@ -1991,11 +1991,9 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n \t/*\n \t * We don't want to descend into paths that don't match the necessary\n \t * patterns.  Clearly, if we don't have a pathspec, then we can't check\n-\t * for matching patterns.  Also, if (excluded) then we know we matched\n-\t * the exclusion patterns so as an optimization we can skip checking\n \t * for matching patterns.\n \t */\n-\tif (pathspec && !excluded) {\n+\tif (pathspec) {\n \t\tmatches_how = match_pathspec_with_flags(istate, pathspec,\n \t\t\t\t\t\t\tdirname, len,\n \t\t\t\t\t\t\t0 /* prefix */,\n\n"},{"id":"552855","messageId":"be53c379-b0e1-4242-8504-e96c2c49d294@web.de","threadId":"66328","inReplyTo":"87ld94klhf.fsf@athena.silentflame.com","subject":"[PATCH] dir: skip excluded directory with nested repo on prefix match","fromName":"René Scharfe","fromEmail":"l.s.r@web.de","sentAt":"2026-09-18T11:04:06Z","receivedAt":"2026-09-18T11:04:28Z","isPatch":true,"body":"95c11ecc73 (Fix error-prone fill_directory() API; make it only return\nmatches, 2020-04-01) optimized away pathspec matching too eagerly and\ncada7308ad (dir: check pathspecs before returning `path_excluded`,\n2020-07-20) fixed that for files and symlinks.\n\nA corner case remained unaddressed: Ignored directories that contain a\nnested repository.  Make sure to run match_pathspec_with_flags() for it\nif necessary.\n\nReported-by: Sean Whitton <spwhitton@spwhitton.name>\nReported-by: Lester Longley <lester@ieee.org>\nSigned-off-by: René Scharfe <l.s.r@web.de>\n---\n dir.c                      | 25 ++++++++++++++++++-------\n t/t7061-wtstatus-ignore.sh |  5 +++++\n 2 files changed, 23 insertions(+), 7 deletions(-)\n\ndiff --git a/dir.c b/dir.c\nindex 95d8a1cce9..0557a59d5e 100644\n--- a/dir.c\n+++ b/dir.c\n@@ -1930,6 +1930,16 @@ static enum exist_status directory_exists_in_index(struct index_state *istate,\n \treturn index_nonexistent;\n }\n \n+static int dir_match(struct index_state *istate,\n+\t\t     const struct pathspec *pathspec,\n+\t\t     const char *dirname, int len)\n+{\n+\treturn match_pathspec_with_flags(istate, pathspec, dirname, len,\n+\t\t\t\t\t 0 /* prefix */,\n+\t\t\t\t\t NULL /* seen */,\n+\t\t\t\t\t DO_MATCH_LEADING_PATHSPEC);\n+}\n+\n /*\n  * When we find a directory when traversing the filesystem, we\n  * have three distinct cases:\n@@ -1996,11 +2006,7 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n \t * for matching patterns.\n \t */\n \tif (pathspec && !excluded) {\n-\t\tmatches_how = match_pathspec_with_flags(istate, pathspec,\n-\t\t\t\t\t\t\tdirname, len,\n-\t\t\t\t\t\t\t0 /* prefix */,\n-\t\t\t\t\t\t\tNULL /* seen */,\n-\t\t\t\t\t\t\tDO_MATCH_LEADING_PATHSPEC);\n+\t\tmatches_how = dir_match(istate, pathspec, dirname, len);\n \t\tif (!matches_how)\n \t\t\treturn path_none;\n \t}\n@@ -2034,8 +2040,13 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n \t\tstrbuf_release(&sb);\n \n \t\tif (nested_repo) {\n-\t\t\tif ((dir->flags & DIR_SKIP_NESTED_GIT) ||\n-\t\t\t\t(matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC))\n+\t\t\tif (dir->flags & DIR_SKIP_NESTED_GIT)\n+\t\t\t\treturn path_none;\n+\t\t\tif (!matches_how)\n+\t\t\t\tmatches_how = dir_match(istate, pathspec,\n+\t\t\t\t\t\t\tdirname, len);\n+\t\t\tif (!matches_how ||\n+\t\t\t    matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n \t\t\t\treturn path_none;\n \t\t\treturn excluded ? path_excluded : path_untracked;\n \t\t}\ndiff --git a/t/t7061-wtstatus-ignore.sh b/t/t7061-wtstatus-ignore.sh\nindex 14ddaba2f3..182933471f 100755\n--- a/t/t7061-wtstatus-ignore.sh\n+++ b/t/t7061-wtstatus-ignore.sh\n@@ -340,4 +340,9 @@ test_expect_success 'status ignores submodule in excluded directory' '\n \ttest_cmp expected actual\n '\n \n+test_expect_success 'status omits excluded directory with submodule on prefix match' '\n+\tgit status --porcelain --ignored -u tracke >actual &&\n+\ttest_must_be_empty actual\n+'\n+\n test_done\n-- \n2.55.0\n"},{"id":"552969","messageId":"xmqqjyodj320.fsf@gitster.g","threadId":"66328","inReplyTo":"be53c379-b0e1-4242-8504-e96c2c49d294@web.de","subject":"Re: [PATCH] dir: skip excluded directory with nested repo on prefix match","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-22T04:57:43Z","receivedAt":"2026-09-22T04:57:46Z","isPatch":true,"body":"René Scharfe <l.s.r@web.de> writes:\n\n> +static int dir_match(struct index_state *istate,\n> +\t\t     const struct pathspec *pathspec,\n> +\t\t     const char *dirname, int len)\n> +{\n> +\treturn match_pathspec_with_flags(istate, pathspec, dirname, len,\n> +\t\t\t\t\t 0 /* prefix */,\n> +\t\t\t\t\t NULL /* seen */,\n> +\t\t\t\t\t DO_MATCH_LEADING_PATHSPEC);\n> +}\n\nOK, this is a good helper to extract and reuse.\n\n> @@ -1996,11 +2006,7 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n>  \t * for matching patterns.\n>  \t */\n>  \tif (pathspec && !excluded) {\n> -\t\tmatches_how = match_pathspec_with_flags(istate, pathspec,\n> -\t\t\t\t\t\t\tdirname, len,\n> -\t\t\t\t\t\t\t0 /* prefix */,\n> -\t\t\t\t\t\t\tNULL /* seen */,\n> -\t\t\t\t\t\t\tDO_MATCH_LEADING_PATHSPEC);\n> +\t\tmatches_how = dir_match(istate, pathspec, dirname, len);\n>  \t\tif (!matches_how)\n>  \t\t\treturn path_none;\n>  \t}\n\nSo, this hunk shows that the treat_directory() function is prepared\nto be passed a NULL in pathspec.  We make sure we do not trigger\nmatch_pathspec_with_flags() when pathspec is NULL, and this should\nextends to dir_match() now.  This is a very sensible conditional, as\nmatch_pathspec_with_flags() calls do_match_pathspec() as the first\nthing, which begins with GUARD_PATHSPEC() macro that unconditionally\ndereferences the pathspec.\n\n> @@ -2034,8 +2040,13 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n>  \t\tstrbuf_release(&sb);\n>  \n>  \t\tif (nested_repo) {\n> -\t\t\tif ((dir->flags & DIR_SKIP_NESTED_GIT) ||\n> -\t\t\t\t(matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC))\n> +\t\t\tif (dir->flags & DIR_SKIP_NESTED_GIT)\n> +\t\t\t\treturn path_none;\n> +\t\t\tif (!matches_how)\n> +\t\t\t\tmatches_how = dir_match(istate, pathspec,\n> +\t\t\t\t\t\t\tdirname, len);\n> +\t\t\tif (!matches_how ||\n> +\t\t\t    matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n>  \t\t\t\treturn path_none;\n\nHere, we do not know if we have pathspec==NULL.  Shouldn't \"make\nsure we have a result from dir_match() and return path_none as\nappropriate\" be done only when pathspec != NULL or something like\nthat, since dir_match() will crash if pathspec is NULL?\n\nTaking all together, something along the following line squashed\ninto your patch, perhaps?  The newly added test is not essential; it\nmerely is to demonstrate why an extra conditional I added below\nwould help avoid segfaulting.\n\n dir.c                      | 15 +++++++++------\n t/t7061-wtstatus-ignore.sh | 25 +++++++++++++++++++++++++\n 2 files changed, 34 insertions(+), 6 deletions(-)\n\ndiff --git c/dir.c w/dir.c\nindex 6bda650891..8e858c26d1 100644\n--- c/dir.c\n+++ w/dir.c\n@@ -2042,12 +2042,15 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n \t\tif (nested_repo) {\n \t\t\tif (dir->flags & DIR_SKIP_NESTED_GIT)\n \t\t\t\treturn path_none;\n-\t\t\tif (!matches_how)\n-\t\t\t\tmatches_how = dir_match(istate, pathspec,\n-\t\t\t\t\t\t\tdirname, len);\n-\t\t\tif (!matches_how ||\n-\t\t\t    matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n-\t\t\t\treturn path_none;\n+\n+\t\t\tif (pathspec) {\n+\t\t\t\tif (!matches_how)\n+\t\t\t\t\tmatches_how = dir_match(istate, pathspec,\n+\t\t\t\t\t\t\t\tdirname, len);\n+\t\t\t\tif (!matches_how ||\n+\t\t\t\t    matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n+\t\t\t\t\treturn path_none;\n+\t\t\t}\n \t\t\treturn excluded ? path_excluded : path_untracked;\n \t\t}\n \t}\ndiff --git c/t/t7061-wtstatus-ignore.sh w/t/t7061-wtstatus-ignore.sh\nindex 182933471f..6bc680312e 100755\n--- c/t/t7061-wtstatus-ignore.sh\n+++ w/t/t7061-wtstatus-ignore.sh\n@@ -340,6 +340,31 @@ test_expect_success 'status ignores submodule in excluded directory' '\n \ttest_cmp expected actual\n '\n \n+test_expect_success 'replace directory with untracked nested repo' '\n+\ttest_create_repo checkout-nested &&\n+\t(\n+\t\tcd checkout-nested &&\n+\t\ttest_commit base &&\n+\n+\t\t# Branch with tracked file \"dir\"\n+\t\tgit checkout -b branch-file &&\n+\t\techo \"tracked file\" >dir &&\n+\t\tgit add dir &&\n+\t\tgit commit -m \"add file dir\" &&\n+\n+\t\t# Switch back to base\n+\t\tgit checkout -b branch-nested base &&\n+\n+\t\t# Create an untracked directory containing a nested git repo\n+\t\tmkdir -p dir/nested &&\n+\t\tgit init dir/nested &&\n+\n+\t\t# Attempting checkout invokes verify_clean_subdirectory()\n+\t\t# with pathspec=NULL\n+\t\ttest_must_fail git checkout branch-file\n+\t)\n+'\n+\n test_expect_success 'status omits excluded directory with submodule on prefix match' '\n \tgit status --porcelain --ignored -u tracke >actual &&\n \ttest_must_be_empty actual\n"},{"id":"553345","messageId":"1c6ac703-a5a5-421b-bf0b-640bc1a0932f@web.de","threadId":"66328","inReplyTo":"be53c379-b0e1-4242-8504-e96c2c49d294@web.de","subject":"[PATCH v2] dir: skip excluded directory with nested repo on prefix match","fromName":"René Scharfe","fromEmail":"l.s.r@web.de","sentAt":"2026-09-26T10:48:29Z","receivedAt":"2026-09-26T10:48:50Z","isPatch":true,"body":"95c11ecc73 (Fix error-prone fill_directory() API; make it only return\nmatches, 2020-04-01) optimized away pathspec matching too eagerly and\ncada7308ad (dir: check pathspecs before returning `path_excluded`,\n2020-07-20) fixed that for files and symlinks.\n\nA corner case remained unaddressed: Ignored directories that contain a\nnested repository.  Make sure to run match_pathspec_with_flags() for it\nif necessary.\n\nAdd a status test to check whether the pathspec is applied correctly as\nwell as a checkout test to exercise the changed code without a pathspec.\n\nReported-by: Sean Whitton <spwhitton@spwhitton.name>\nReported-by: Lester Longley <lester@ieee.org>\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: René Scharfe <l.s.r@web.de>\n---\n dir.c                         | 27 ++++++++++++++++++++-------\n t/t2021-checkout-overwrite.sh |  7 +++++++\n t/t7061-wtstatus-ignore.sh    |  5 +++++\n 3 files changed, 32 insertions(+), 7 deletions(-)\n\ndiff --git a/dir.c b/dir.c\nindex d896e7be4b..ac246ced1d 100644\n--- a/dir.c\n+++ b/dir.c\n@@ -1935,6 +1935,16 @@ static enum exist_status directory_exists_in_index(struct index_state *istate,\n \treturn index_nonexistent;\n }\n \n+static int dir_match(struct index_state *istate,\n+\t\t     const struct pathspec *pathspec,\n+\t\t     const char *dirname, int len)\n+{\n+\treturn match_pathspec_with_flags(istate, pathspec, dirname, len,\n+\t\t\t\t\t 0 /* prefix */,\n+\t\t\t\t\t NULL /* seen */,\n+\t\t\t\t\t DO_MATCH_LEADING_PATHSPEC);\n+}\n+\n /*\n  * When we find a directory when traversing the filesystem, we\n  * have three distinct cases:\n@@ -2001,11 +2011,7 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n \t * for matching patterns.\n \t */\n \tif (pathspec && !excluded) {\n-\t\tmatches_how = match_pathspec_with_flags(istate, pathspec,\n-\t\t\t\t\t\t\tdirname, len,\n-\t\t\t\t\t\t\t0 /* prefix */,\n-\t\t\t\t\t\t\tNULL /* seen */,\n-\t\t\t\t\t\t\tDO_MATCH_LEADING_PATHSPEC);\n+\t\tmatches_how = dir_match(istate, pathspec, dirname, len);\n \t\tif (!matches_how)\n \t\t\treturn path_none;\n \t}\n@@ -2039,8 +2045,15 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n \t\tstrbuf_release(&sb);\n \n \t\tif (nested_repo) {\n-\t\t\tif ((dir->flags & DIR_SKIP_NESTED_GIT) ||\n-\t\t\t\t(matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC))\n+\t\t\tif (dir->flags & DIR_SKIP_NESTED_GIT)\n+\t\t\t\treturn path_none;\n+\t\t\tif (pathspec && !matches_how) {\n+\t\t\t\tmatches_how = dir_match(istate, pathspec,\n+\t\t\t\t\t\t\tdirname, len);\n+\t\t\t\tif (!matches_how)\n+\t\t\t\t\treturn path_none;\n+\t\t\t}\n+\t\t\tif (matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n \t\t\t\treturn path_none;\n \t\t\treturn excluded ? path_excluded : path_untracked;\n \t\t}\ndiff --git a/t/t2021-checkout-overwrite.sh b/t/t2021-checkout-overwrite.sh\nindex 38c41ae373..2b7fff5159 100755\n--- a/t/t2021-checkout-overwrite.sh\n+++ b/t/t2021-checkout-overwrite.sh\n@@ -79,4 +79,11 @@ test_expect_success 'checkout --overwrite-ignore should succeed if only ignored\n \ttest_path_is_file some_dir\n '\n \n+test_expect_success 'checkout must not overwrite untracked nested repo' '\n+\tgit checkout -f start &&\n+\trm -rf some_dir &&\n+\tgit init some_dir &&\n+\ttest_must_fail git checkout df_conflict\n+'\n+\n test_done\ndiff --git a/t/t7061-wtstatus-ignore.sh b/t/t7061-wtstatus-ignore.sh\nindex 14ddaba2f3..182933471f 100755\n--- a/t/t7061-wtstatus-ignore.sh\n+++ b/t/t7061-wtstatus-ignore.sh\n@@ -340,4 +340,9 @@ test_expect_success 'status ignores submodule in excluded directory' '\n \ttest_cmp expected actual\n '\n \n+test_expect_success 'status omits excluded directory with submodule on prefix match' '\n+\tgit status --porcelain --ignored -u tracke >actual &&\n+\ttest_must_be_empty actual\n+'\n+\n test_done\n\nInterdiff against v1:\n  diff --git a/dir.c b/dir.c\n  index aa457f0f4e..ac246ced1d 100644\n  --- a/dir.c\n  +++ b/dir.c\n  @@ -2047,11 +2047,13 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n   \t\tif (nested_repo) {\n   \t\t\tif (dir->flags & DIR_SKIP_NESTED_GIT)\n   \t\t\t\treturn path_none;\n  -\t\t\tif (!matches_how)\n  +\t\t\tif (pathspec && !matches_how) {\n   \t\t\t\tmatches_how = dir_match(istate, pathspec,\n   \t\t\t\t\t\t\tdirname, len);\n  -\t\t\tif (!matches_how ||\n  -\t\t\t    matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n  +\t\t\t\tif (!matches_how)\n  +\t\t\t\t\treturn path_none;\n  +\t\t\t}\n  +\t\t\tif (matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n   \t\t\t\treturn path_none;\n   \t\t\treturn excluded ? path_excluded : path_untracked;\n   \t\t}\n  diff --git a/t/t2021-checkout-overwrite.sh b/t/t2021-checkout-overwrite.sh\n  index 38c41ae373..2b7fff5159 100755\n  --- a/t/t2021-checkout-overwrite.sh\n  +++ b/t/t2021-checkout-overwrite.sh\n  @@ -79,4 +79,11 @@ test_expect_success 'checkout --overwrite-ignore should succeed if only ignored\n   \ttest_path_is_file some_dir\n   '\n   \n  +test_expect_success 'checkout must not overwrite untracked nested repo' '\n  +\tgit checkout -f start &&\n  +\trm -rf some_dir &&\n  +\tgit init some_dir &&\n  +\ttest_must_fail git checkout df_conflict\n  +'\n  +\n   test_done\n-- \n2.55.0\n"},{"id":"553346","messageId":"78937658-ac31-4ec9-8f8a-ce8fb74ed196@web.de","threadId":"66328","inReplyTo":"xmqqjyodj320.fsf@gitster.g","subject":"Re: [PATCH] dir: skip excluded directory with nested repo on prefix match","fromName":"René Scharfe","fromEmail":"l.s.r@web.de","sentAt":"2026-09-26T10:51:30Z","receivedAt":"2026-09-26T10:51:52Z","isPatch":true,"body":"On 9/22/26 6:57 AM, Junio C Hamano wrote:\n> René Scharfe <l.s.r@web.de> writes:\n> \n>> @@ -2034,8 +2040,13 @@ static enum path_treatment treat_directory(struct dir_struct *dir,\n>>  \t\tstrbuf_release(&sb);\n>>  \n>>  \t\tif (nested_repo) {\n>> -\t\t\tif ((dir->flags & DIR_SKIP_NESTED_GIT) ||\n>> -\t\t\t\t(matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC))\n>> +\t\t\tif (dir->flags & DIR_SKIP_NESTED_GIT)\n>> +\t\t\t\treturn path_none;\n>> +\t\t\tif (!matches_how)\n>> +\t\t\t\tmatches_how = dir_match(istate, pathspec,\n>> +\t\t\t\t\t\t\tdirname, len);\n>> +\t\t\tif (!matches_how ||\n>> +\t\t\t    matches_how == MATCHED_RECURSIVELY_LEADING_PATHSPEC)\n>>  \t\t\t\treturn path_none;\n> \n> Here, we do not know if we have pathspec==NULL.  Shouldn't \"make\n> sure we have a result from dir_match() and return path_none as\n> appropriate\" be done only when pathspec != NULL or something like\n> that, since dir_match() will crash if pathspec is NULL?\n\nUgh, nasty, that was silly of me.  Thanks for finding this bug!\n\nRené\n\n"}]}