diff mbox series

[v2,6/7] add: warn when pathspec only matches SKIP_WORKTREE entries

Message ID 24e889ca9b1cb0823d1663a60ce1e0ba9e55f875.1614138107.git.matheus.bernardino@usp.br (mailing list archive)
State Superseded
Headers show
Series add/rm: honor sparse checkout and warn on sparse paths | expand

Commit Message

Matheus Tavares Feb. 24, 2021, 4:05 a.m. UTC
`git add` already refrains from updating SKIP_WORKTREE entries, but it
silently exits with zero code when a pathspec only matches these
entries. Instead, let's warn the user and display a hint on how to
update these entries.

Note that the warning is only shown if the pathspec matches no untracked
paths in the working tree and only matches index entries with the
SKIP_WORKTREE bit set. A warning message was chosen over erroring out
right away to reproduce the same behavior `add` already exhibits with
ignored files. This also allow users to continue their workflow without
having to invoke `add` again with only the matching pathspecs, as the
matched files will have already been added.

Signed-off-by: Matheus Tavares <matheus.bernardino@usp.br>
---
 Documentation/config/advice.txt |  3 ++
 advice.c                        | 19 +++++++++
 advice.h                        |  4 ++
 builtin/add.c                   | 70 ++++++++++++++++++++++++-------
 pathspec.c                      | 15 +++++++
 pathspec.h                      |  8 ++++
 t/t3705-add-sparse-checkout.sh  | 73 +++++++++++++++++++++++++++++----
 7 files changed, 171 insertions(+), 21 deletions(-)

Comments

Elijah Newren Feb. 24, 2021, 6:50 a.m. UTC | #1
On Tue, Feb 23, 2021 at 8:05 PM Matheus Tavares
<matheus.bernardino@usp.br> wrote:
>
> `git add` already refrains from updating SKIP_WORKTREE entries, but it
> silently exits with zero code when a pathspec only matches these
> entries. Instead, let's warn the user and display a hint on how to
> update these entries.
>
> Note that the warning is only shown if the pathspec matches no untracked
> paths in the working tree and only matches index entries with the
> SKIP_WORKTREE bit set. A warning message was chosen over erroring out
> right away to reproduce the same behavior `add` already exhibits with
> ignored files. This also allow users to continue their workflow without
> having to invoke `add` again with only the matching pathspecs, as the
> matched files will have already been added.
>
> Signed-off-by: Matheus Tavares <matheus.bernardino@usp.br>
> ---
>  Documentation/config/advice.txt |  3 ++
>  advice.c                        | 19 +++++++++
>  advice.h                        |  4 ++
>  builtin/add.c                   | 70 ++++++++++++++++++++++++-------
>  pathspec.c                      | 15 +++++++
>  pathspec.h                      |  8 ++++
>  t/t3705-add-sparse-checkout.sh  | 73 +++++++++++++++++++++++++++++----
>  7 files changed, 171 insertions(+), 21 deletions(-)
>
> diff --git a/Documentation/config/advice.txt b/Documentation/config/advice.txt
> index acbd0c09aa..d53eafa00b 100644
> --- a/Documentation/config/advice.txt
> +++ b/Documentation/config/advice.txt
> @@ -119,4 +119,7 @@ advice.*::
>         addEmptyPathspec::
>                 Advice shown if a user runs the add command without providing
>                 the pathspec parameter.
> +       updateSparsePath::
> +               Advice shown if the pathspec given to linkgit:git-add[1] only
> +               matches index entries outside the current sparse-checkout.
>  --
> diff --git a/advice.c b/advice.c
> index 164742305f..cf22c1a6e5 100644
> --- a/advice.c
> +++ b/advice.c
> @@ -2,6 +2,7 @@
>  #include "config.h"
>  #include "color.h"
>  #include "help.h"
> +#include "string-list.h"
>
>  int advice_fetch_show_forced_updates = 1;
>  int advice_push_update_rejected = 1;
> @@ -136,6 +137,7 @@ static struct {
>         [ADVICE_STATUS_HINTS]                           = { "statusHints", 1 },
>         [ADVICE_STATUS_U_OPTION]                        = { "statusUoption", 1 },
>         [ADVICE_SUBMODULE_ALTERNATE_ERROR_STRATEGY_DIE] = { "submoduleAlternateErrorStrategyDie", 1 },
> +       [ADVICE_UPDATE_SPARSE_PATH]                     = { "updateSparsePath", 1 },
>         [ADVICE_WAITING_FOR_EDITOR]                     = { "waitingForEditor", 1 },
>  };
>
> @@ -284,6 +286,23 @@ void NORETURN die_conclude_merge(void)
>         die(_("Exiting because of unfinished merge."));
>  }
>
> +void advise_on_updating_sparse_paths(struct string_list *pathspec_list)
> +{
> +       struct string_list_item *item;
> +
> +       if (!pathspec_list->nr)
> +               return;
> +
> +       fprintf(stderr, _("The following pathspecs only matched index entries outside the current\n"
> +                         "sparse checkout:\n"));
> +       for_each_string_list_item(item, pathspec_list)
> +               fprintf(stderr, "%s\n", item->string);

Was the use of fprintf(stderr, ...) because of the fact that you want
to do multiple print statements?  I'm just curious if that was the
reason for avoiding the warning() function, or if there was another
consideration at play as well.

> +
> +       advise_if_enabled(ADVICE_UPDATE_SPARSE_PATH,
> +                         _("Disable or modify the sparsity rules if you intend to update such entries."));
> +
> +}
> +
>  void detach_advice(const char *new_name)
>  {
>         const char *fmt =
> diff --git a/advice.h b/advice.h
> index bc2432980a..bd26c385d0 100644
> --- a/advice.h
> +++ b/advice.h
> @@ -3,6 +3,8 @@
>
>  #include "git-compat-util.h"
>
> +struct string_list;
> +
>  extern int advice_fetch_show_forced_updates;
>  extern int advice_push_update_rejected;
>  extern int advice_push_non_ff_current;
> @@ -71,6 +73,7 @@ extern int advice_add_empty_pathspec;
>         ADVICE_STATUS_HINTS,
>         ADVICE_STATUS_U_OPTION,
>         ADVICE_SUBMODULE_ALTERNATE_ERROR_STRATEGY_DIE,
> +       ADVICE_UPDATE_SPARSE_PATH,
>         ADVICE_WAITING_FOR_EDITOR,
>  };
>
> @@ -92,6 +95,7 @@ void advise_if_enabled(enum advice_type type, const char *advice, ...);
>  int error_resolve_conflict(const char *me);
>  void NORETURN die_resolve_conflict(const char *me);
>  void NORETURN die_conclude_merge(void);
> +void advise_on_updating_sparse_paths(struct string_list *pathspec_list);
>  void detach_advice(const char *new_name);
>
>  #endif /* ADVICE_H */
> diff --git a/builtin/add.c b/builtin/add.c
> index e15b25a623..fde6462850 100644
> --- a/builtin/add.c
> +++ b/builtin/add.c
> @@ -177,24 +177,43 @@ static char *prune_directory(struct dir_struct *dir, struct pathspec *pathspec,
>                         *dst++ = entry;
>         }
>         dir->nr = dst - dir->entries;
> -       add_pathspec_matches_against_index(pathspec, &the_index, seen, 0);
> +       add_pathspec_matches_against_index(pathspec, &the_index, seen, 1);

_If_ you add the enum as mentioned earlier, this 1 would become
whatever the other enum value was.  I'll omit making similar comments
for other call sites like this one, though there are a few more in the
patch.

>         return seen;
>  }
>
> -static void refresh(int verbose, const struct pathspec *pathspec)
> +static int refresh(int verbose, const struct pathspec *pathspec)
>  {
>         char *seen;
> -       int i;
> +       int i, ret = 0;
> +       char *skip_worktree_seen = NULL;
> +       struct string_list only_match_skip_worktree = STRING_LIST_INIT_NODUP;
> +       int flags = REFRESH_DONT_MARK_SPARSE_MATCHES |
> +                   (verbose ? REFRESH_IN_PORCELAIN : REFRESH_QUIET);
>
>         seen = xcalloc(pathspec->nr, 1);
> -       refresh_index(&the_index, verbose ? REFRESH_IN_PORCELAIN : REFRESH_QUIET,
> -                     pathspec, seen, _("Unstaged changes after refreshing the index:"));
> +       refresh_index(&the_index, flags, pathspec, seen,
> +                     _("Unstaged changes after refreshing the index:"));
>         for (i = 0; i < pathspec->nr; i++) {
> -               if (!seen[i])
> -                       die(_("pathspec '%s' did not match any files"),
> -                           pathspec->items[i].original);
> +               if (!seen[i]) {
> +                       if (matches_skip_worktree(pathspec, i, &skip_worktree_seen)) {
> +                               string_list_append(&only_match_skip_worktree,
> +                                                  pathspec->items[i].original);
> +                       } else {
> +                               die(_("pathspec '%s' did not match any files"),
> +                                   pathspec->items[i].original);
> +                       }
> +               }
> +       }
> +
> +       if (only_match_skip_worktree.nr) {
> +               advise_on_updating_sparse_paths(&only_match_skip_worktree);
> +               ret = 1;
>         }

On first reading, I missed that the code die()s if there are any
non-SKIP_WORKTREE entries matched, and that is the reason you know
that only SKIP_WORKTREE entries could have been matched for this last
if-statement.  Perhaps a short comment for the reader like me who is
prone to miss the obvious?

> +
>         free(seen);
> +       free(skip_worktree_seen);
> +       string_list_clear(&only_match_skip_worktree, 0);
> +       return ret;
>  }
>
>  int run_add_interactive(const char *revision, const char *patch_mode,
> @@ -570,15 +589,17 @@ int cmd_add(int argc, const char **argv, const char *prefix)
>         }
>
>         if (refresh_only) {
> -               refresh(verbose, &pathspec);
> +               exit_status |= refresh(verbose, &pathspec);
>                 goto finish;
>         }
>
>         if (pathspec.nr) {
>                 int i;
> +               char *skip_worktree_seen = NULL;
> +               struct string_list only_match_skip_worktree = STRING_LIST_INIT_NODUP;
>
>                 if (!seen)
> -                       seen = find_pathspecs_matching_against_index(&pathspec, &the_index, 0);
> +                       seen = find_pathspecs_matching_against_index(&pathspec, &the_index, 1);
>
>                 /*
>                  * file_exists() assumes exact match
> @@ -592,12 +613,24 @@ int cmd_add(int argc, const char **argv, const char *prefix)
>
>                 for (i = 0; i < pathspec.nr; i++) {
>                         const char *path = pathspec.items[i].match;
> +
>                         if (pathspec.items[i].magic & PATHSPEC_EXCLUDE)
>                                 continue;
> -                       if (!seen[i] && path[0] &&
> -                           ((pathspec.items[i].magic &
> -                             (PATHSPEC_GLOB | PATHSPEC_ICASE)) ||
> -                            !file_exists(path))) {
> +                       if (seen[i])
> +                               continue;
> +
> +                       if (matches_skip_worktree(&pathspec, i, &skip_worktree_seen)) {
> +                               string_list_append(&only_match_skip_worktree,
> +                                                  pathspec.items[i].original);
> +                               continue;
> +                       }
> +
> +                       /* Don't complain at 'git add .' inside empty repo. */
> +                       if (!path[0])
> +                               continue;
> +
> +                       if ((pathspec.items[i].magic & (PATHSPEC_GLOB | PATHSPEC_ICASE)) ||
> +                           !file_exists(path)) {

Breaking up that if-statement into several with some continues seems
to make it a lot easier for me to read; thanks.  It also makes it
easier to add your new condition.

>                                 if (ignore_missing) {
>                                         int dtype = DT_UNKNOWN;
>                                         if (is_excluded(&dir, &the_index, path, &dtype))
> @@ -608,7 +641,16 @@ int cmd_add(int argc, const char **argv, const char *prefix)
>                                             pathspec.items[i].original);
>                         }
>                 }
> +
> +
> +               if (only_match_skip_worktree.nr) {
> +                       advise_on_updating_sparse_paths(&only_match_skip_worktree);
> +                       exit_status = 1;
> +               }

Hmm...here's an interesting command sequence:

git init lame
cd lame
mkdir baz
touch baz/tracked
git add baz/tracked
git update-index --skip-worktree baz/tracked
rm baz/tracked.  # But leave the empty directory!
echo baz >.gitignore
git add --ignore-missing --dry-run baz


Reports the following:
"""
The following pathspecs only matched index entries outside the current
sparse checkout:
baz
hint: Disable or modify the sparsity rules if you intend to update such entries.
hint: Disable this message with "git config advice.updateSparsePath false"
The following paths are ignored by one of your .gitignore files:
baz
hint: Use -f if you really want to add them.
hint: Turn this message off by running
hint: "git config advice.addIgnoredFile false"
"""

That's probably okay because it does match both, but the "only
matched" in the first message followed by saying it matched something
else seems a little surprising at first.  It's not wrong, just
slightly surprising.  But then again, this setup is super weird...so
maybe it's all okay?

> +
>                 free(seen);
> +               free(skip_worktree_seen);
> +               string_list_clear(&only_match_skip_worktree, 0);
>         }
>
>         plug_bulk_checkin();
> diff --git a/pathspec.c b/pathspec.c
> index e5e6b7458d..61f294fed5 100644
> --- a/pathspec.c
> +++ b/pathspec.c
> @@ -62,6 +62,21 @@ char *find_pathspecs_matching_against_index(const struct pathspec *pathspec,
>         return seen;
>  }
>
> +char *find_pathspecs_matching_skip_worktree(const struct pathspec *pathspec)
> +{
> +       struct index_state *istate = the_repository->index;
> +       char *seen = xcalloc(pathspec->nr, 1);
> +       int i;
> +
> +       for (i = 0; i < istate->cache_nr; i++) {
> +               struct cache_entry *ce = istate->cache[i];
> +               if (ce_skip_worktree(ce))
> +                   ce_path_match(istate, ce, pathspec, seen);
> +       }
> +
> +       return seen;
> +}
> +
>  /*
>   * Magic pathspec
>   *
> diff --git a/pathspec.h b/pathspec.h
> index 8202882ecd..f591ba625c 100644
> --- a/pathspec.h
> +++ b/pathspec.h
> @@ -155,6 +155,14 @@ void add_pathspec_matches_against_index(const struct pathspec *pathspec,
>  char *find_pathspecs_matching_against_index(const struct pathspec *pathspec,
>                                             const struct index_state *istate,
>                                             int ignore_skip_worktree);
> +char *find_pathspecs_matching_skip_worktree(const struct pathspec *pathspec);
> +static inline int matches_skip_worktree(const struct pathspec *pathspec,
> +                                       int item, char **seen_ptr)
> +{
> +       if (!*seen_ptr)
> +               *seen_ptr = find_pathspecs_matching_skip_worktree(pathspec);
> +       return (*seen_ptr)[item];
> +}
>  int match_pathspec_attrs(const struct index_state *istate,
>                          const char *name, int namelen,
>                          const struct pathspec_item *item);
> diff --git a/t/t3705-add-sparse-checkout.sh b/t/t3705-add-sparse-checkout.sh
> index 6781620297..fdfd8b085e 100755
> --- a/t/t3705-add-sparse-checkout.sh
> +++ b/t/t3705-add-sparse-checkout.sh
> @@ -36,10 +36,26 @@ setup_gitignore () {
>         EOF
>  }
>
> +test_expect_success 'setup' '
> +       cat >sparse_error_header <<-EOF &&
> +       The following pathspecs only matched index entries outside the current
> +       sparse checkout:
> +       EOF
> +
> +       cat >sparse_hint <<-EOF &&
> +       hint: Disable or modify the sparsity rules if you intend to update such entries.
> +       hint: Disable this message with "git config advice.updateSparsePath false"
> +       EOF
> +
> +       echo sparse_entry | cat sparse_error_header - >sparse_entry_error &&
> +       cat sparse_entry_error sparse_hint >error_and_hint
> +'
> +
>  test_expect_success 'git add does not remove sparse entries' '
>         setup_sparse_entry &&
>         rm sparse_entry &&
> -       git add sparse_entry &&
> +       test_must_fail git add sparse_entry 2>stderr &&
> +       test_i18ncmp error_and_hint stderr &&
>         test_sparse_entry_unchanged
>  '
>
> @@ -47,7 +63,8 @@ test_expect_success 'git add -A does not remove sparse entries' '
>         setup_sparse_entry &&
>         rm sparse_entry &&
>         setup_gitignore &&
> -       git add -A &&
> +       git add -A 2>stderr &&
> +       test_must_be_empty stderr &&
>         test_sparse_entry_unchanged
>  '
>
> @@ -55,7 +72,13 @@ test_expect_success 'git add . does not remove sparse entries' '
>         setup_sparse_entry &&
>         rm sparse_entry &&
>         setup_gitignore &&
> -       git add . &&
> +       test_must_fail git add . 2>stderr &&
> +
> +       cat sparse_error_header >expect &&
> +       echo . >>expect &&
> +       cat sparse_hint >>expect &&
> +
> +       test_i18ncmp expect stderr &&
>         test_sparse_entry_unchanged
>  '
>
> @@ -64,7 +87,8 @@ do
>         test_expect_success "git add${opt:+ $opt} does not update sparse entries" '
>                 setup_sparse_entry &&
>                 echo modified >sparse_entry &&
> -               git add $opt sparse_entry &&
> +               test_must_fail git add $opt sparse_entry 2>stderr &&
> +               test_i18ncmp error_and_hint stderr &&
>                 test_sparse_entry_unchanged
>         '
>  done
> @@ -73,14 +97,16 @@ test_expect_success 'git add --refresh does not update sparse entries' '
>         setup_sparse_entry &&
>         git ls-files --debug sparse_entry | grep mtime >before &&
>         test-tool chmtime -60 sparse_entry &&
> -       git add --refresh sparse_entry &&
> +       test_must_fail git add --refresh sparse_entry 2>stderr &&
> +       test_i18ncmp error_and_hint stderr &&
>         git ls-files --debug sparse_entry | grep mtime >after &&
>         test_cmp before after
>  '
>
>  test_expect_success 'git add --chmod does not update sparse entries' '
>         setup_sparse_entry &&
> -       git add --chmod=+x sparse_entry &&
> +       test_must_fail git add --chmod=+x sparse_entry 2>stderr &&
> +       test_i18ncmp error_and_hint stderr &&
>         test_sparse_entry_unchanged &&
>         ! test -x sparse_entry
>  '
> @@ -89,8 +115,41 @@ test_expect_success 'git add --renormalize does not update sparse entries' '
>         test_config core.autocrlf false &&
>         setup_sparse_entry "LINEONE\r\nLINETWO\r\n" &&
>         echo "sparse_entry text=auto" >.gitattributes &&
> -       git add --renormalize sparse_entry &&
> +       test_must_fail git add --renormalize sparse_entry 2>stderr &&
> +       test_i18ncmp error_and_hint stderr &&
>         test_sparse_entry_unchanged
>  '
>
> +test_expect_success 'git add --dry-run --ignore-missing warn on sparse path' '
> +       setup_sparse_entry &&
> +       rm sparse_entry &&
> +       test_must_fail git add --dry-run --ignore-missing sparse_entry 2>stderr &&
> +       test_i18ncmp error_and_hint stderr &&
> +       test_sparse_entry_unchanged

See also the slightly convoluted setup I responded above with.  I'm
not sure if we need to make it a test, in part because it's crazy
enough that I'm not quite convinced that the current behavior is right
-- or wrong.

> +'
> +
> +test_expect_success 'do not advice about sparse entries when they do not match the pathspec' '
> +       setup_sparse_entry &&
> +       test_must_fail git add nonexistent 2>stderr &&
> +       test_i18ngrep "fatal: pathspec .nonexistent. did not match any files" stderr &&
> +       test_i18ngrep ! "The following pathspecs only matched index entries" stderr
> +'
> +
> +test_expect_success 'do not warn when pathspec matches dense entries' '
> +       setup_sparse_entry &&
> +       echo modified >sparse_entry &&
> +       >dense_entry &&
> +       git add "*_entry" 2>stderr &&
> +       test_must_be_empty stderr &&
> +       test_sparse_entry_unchanged &&
> +       git ls-files --error-unmatch dense_entry
> +'
> +
> +test_expect_success 'add obeys advice.updateSparsePath' '
> +       setup_sparse_entry &&
> +       test_must_fail git -c advice.updateSparsePath=false add sparse_entry 2>stderr &&
> +       test_i18ncmp sparse_entry_error stderr
> +
> +'
> +
>  test_done
> --
> 2.30.1

Overall, a nice read.  Didn't spot any other issues, just the minor
points I highlighted above.  I'm curious if others have thoughts on my
really weird setup and the dual warnings, though.
Matheus Tavares Feb. 24, 2021, 3:33 p.m. UTC | #2
On Wed, Feb 24, 2021 at 3:50 AM Elijah Newren <newren@gmail.com> wrote:
>
> On Tue, Feb 23, 2021 at 8:05 PM Matheus Tavares
> <matheus.bernardino@usp.br> wrote:
> >
> > +void advise_on_updating_sparse_paths(struct string_list *pathspec_list)
> > +{
> > +       struct string_list_item *item;
> > +
> > +       if (!pathspec_list->nr)
> > +               return;
> > +
> > +       fprintf(stderr, _("The following pathspecs only matched index entries outside the current\n"
> > +                         "sparse checkout:\n"));
> > +       for_each_string_list_item(item, pathspec_list)
> > +               fprintf(stderr, "%s\n", item->string);
>
> Was the use of fprintf(stderr, ...) because of the fact that you want
> to do multiple print statements?  I'm just curious if that was the
> reason for avoiding the warning() function, or if there was another
> consideration at play as well.

Yes, that was one of the reasons. The other was to use the same style as
the ignored files message, which doesn't print the "warning:" prefix.
But I don't have any strong preference here, I'd be OK with using
warning() too.

> > -static void refresh(int verbose, const struct pathspec *pathspec)
> > +static int refresh(int verbose, const struct pathspec *pathspec)
> >  {
> >         char *seen;
> > -       int i;
> > +       int i, ret = 0;
> > +       char *skip_worktree_seen = NULL;
> > +       struct string_list only_match_skip_worktree = STRING_LIST_INIT_NODUP;
> > +       int flags = REFRESH_DONT_MARK_SPARSE_MATCHES |
> > +                   (verbose ? REFRESH_IN_PORCELAIN : REFRESH_QUIET);
> >
> >         seen = xcalloc(pathspec->nr, 1);
> > -       refresh_index(&the_index, verbose ? REFRESH_IN_PORCELAIN : REFRESH_QUIET,
> > -                     pathspec, seen, _("Unstaged changes after refreshing the index:"));
> > +       refresh_index(&the_index, flags, pathspec, seen,
> > +                     _("Unstaged changes after refreshing the index:"));
> >         for (i = 0; i < pathspec->nr; i++) {
> > -               if (!seen[i])
> > -                       die(_("pathspec '%s' did not match any files"),
> > -                           pathspec->items[i].original);
> > +               if (!seen[i]) {
> > +                       if (matches_skip_worktree(pathspec, i, &skip_worktree_seen)) {
> > +                               string_list_append(&only_match_skip_worktree,
> > +                                                  pathspec->items[i].original);
> > +                       } else {
> > +                               die(_("pathspec '%s' did not match any files"),
> > +                                   pathspec->items[i].original);
> > +                       }
> > +               }
> > +       }
> > +
> > +       if (only_match_skip_worktree.nr) {
> > +               advise_on_updating_sparse_paths(&only_match_skip_worktree);
> > +               ret = 1;
> >         }
>
> On first reading, I missed that the code die()s if there are any
> non-SKIP_WORKTREE entries matched, and that is the reason you know
> that only SKIP_WORKTREE entries could have been matched for this last
> if-statement.

Hmm, I may be misinterpreting your explanation, but I think the
reasoning is slightly different. The code die()s if there are _no_
matches either among sparse or dense entries. The reason why we know
that only sparse entries matched the pathspecs in this last if-statement
is because the `only_match_skip_worktree` list is only appended when a
pathspec is not marked on seen[] (dense entries only), but it is marked
on skip_worktree_seen[] (sparse entries only).

> Hmm...here's an interesting command sequence:
>
> git init lame
> cd lame
> mkdir baz
> touch baz/tracked
> git add baz/tracked
> git update-index --skip-worktree baz/tracked
> rm baz/tracked.  # But leave the empty directory!
> echo baz >.gitignore
> git add --ignore-missing --dry-run baz
>
>
> Reports the following:
> """
> The following pathspecs only matched index entries outside the current
> sparse checkout:
> baz
> hint: Disable or modify the sparsity rules if you intend to update such entries.
> hint: Disable this message with "git config advice.updateSparsePath false"
> The following paths are ignored by one of your .gitignore files:
> baz
> hint: Use -f if you really want to add them.
> hint: Turn this message off by running
> hint: "git config advice.addIgnoredFile false"
> """

That's interesting. You can also trigger this behavior with a plain add
(i.e. without "--ignore-missing --dry-run").

Since we only get the list of ignored paths from fill_directory(), we
can't really tell whether a specific pathspec item had matches among
ignored files or not. If we had this information, we could conditionally
skip the sparse warning.

I.e. something like this (WARNING: hacky and just briefly tested):

diff --git a/builtin/add.c b/builtin/add.c
index fde6462850..90614e7e76 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -597,3 +597,3 @@ int cmd_add(int argc, const char **argv, const char *prefix)
 		int i;
-		char *skip_worktree_seen = NULL;
+		char *skip_worktree_seen = NULL, *ignored_seen = NULL;
 		struct string_list only_match_skip_worktree = STRING_LIST_INIT_NODUP;
@@ -621,3 +621,14 @@ int cmd_add(int argc, const char **argv, const char *prefix)

-			if (matches_skip_worktree(&pathspec, i, &skip_worktree_seen)) {
+			if (dir.ignored_nr) {
+				int j, prefix_len = common_prefix_len(&pathspec);
+				ignored_seen = xcalloc(pathspec.nr, 1);
+				for (j = 0; j < dir.ignored_nr; j++) {
+					dir_path_match(&the_index, dir.ignored[j],
+						       &pathspec, prefix_len,
+						       ignored_seen);
+				}
+			}
+
+			if (ignored_seen && !ignored_seen[i] &&
+			    matches_skip_worktree(&pathspec, i, &skip_worktree_seen)) {
 				string_list_append(&only_match_skip_worktree,
diff --git a/dir.c b/dir.c
index d153a63bbd..a19bc7aa0b 100644
--- a/dir.c
+++ b/dir.c
@@ -136,3 +136,3 @@ static int fnmatch_icase_mem(const char *pattern, int patternlen,

-static size_t common_prefix_len(const struct pathspec *pathspec)
+size_t common_prefix_len(const struct pathspec *pathspec)
 {
diff --git a/dir.h b/dir.h
index facfae4740..aa2d4aa71b 100644
--- a/dir.h
+++ b/dir.h
@@ -355,2 +355,3 @@ int simple_length(const char *match);
 int no_wildcard(const char *string);
+size_t common_prefix_len(const struct pathspec *pathspec);
 char *common_prefix(const struct pathspec *pathspec);


Now `git add baz` would only produce:

The following paths are ignored by one of your .gitignore files:
baz
hint: Use -f if you really want to add them.
hint: Turn this message off by running
hint: "git config advice.addIgnoredFile false"
Matheus Tavares March 4, 2021, 3:23 p.m. UTC | #3
Hi, Elijah

I was going to send v3 a couple days ago, but there is still one issue
that I'm having a bit of trouble with:

On Wed, Feb 24, 2021 at 3:50 AM Elijah Newren <newren@gmail.com> wrote:
>
> Hmm...here's an interesting command sequence:
>
> git init lame
> cd lame
> mkdir baz
> touch baz/tracked
> git add baz/tracked
> git update-index --skip-worktree baz/tracked
> rm baz/tracked.  # But leave the empty directory!
> echo baz >.gitignore
> git add --ignore-missing --dry-run baz
>
>
> Reports the following:
> """
> The following pathspecs only matched index entries outside the current
> sparse checkout:
> baz
> hint: Disable or modify the sparsity rules if you intend to update such entries.
> hint: Disable this message with "git config advice.updateSparsePath false"
> The following paths are ignored by one of your .gitignore files:
> baz
> hint: Use -f if you really want to add them.
> hint: Turn this message off by running
> hint: "git config advice.addIgnoredFile false"
> """
>
> That's probably okay because it does match both, but the "only
> matched" in the first message followed by saying it matched something
> else seems a little surprising at first.

After giving this a little more thought, I got to this idea [1]. With
this patch, git-add doesn't warn about sparse paths that are inside
ignored dirs (it only warns about the ignored dirs themselves). If
--force is used, then git-add recurses into the ignored directories, and
warns about any sparse path inside them. I think this is more consistent
with the way in which ignored dirs (and their contents) are handled with
and without --force. And we avoid the double warning.

But there is a performance penalty. The relevant code is:

@@ -2029,19 +2033,23 @@ static int exclude_matches_pathspec(const char *path, int pathlen,
 		       PATHSPEC_ICASE |
 		       PATHSPEC_EXCLUDE);
 
+	assert(matched_ignored);
+
 	for (i = 0; i < pathspec->nr; i++) {
 		const struct pathspec_item *item = &pathspec->items[i];
 		int len = item->nowildcard_len;
 
-		if (len == pathlen &&
-		    !ps_strncmp(item, item->match, path, pathlen))
-			return 1;
-		if (len > pathlen &&
-		    item->match[pathlen] == '/' &&
-		    !ps_strncmp(item, item->match, path, pathlen))
-			return 1;
+		if (matched && matched_ignored[i])
+			continue;
+
+		if ((len == pathlen ||
+		    (len > pathlen && item->match[pathlen] == '/')) &&
+		    !ps_strncmp(item, item->match, path, pathlen)) {
+			matched = 1;
+			matched_ignored[i] = 1;
+		}
 	}
-	return 0;
+	return matched;
 }

This function is called for each ignored path (not recursing into
completely ignored dirs, though). Before the patch, the internal loop
stops at the first pathspec match. But with the patch, the loop must
continue as we need to know about all pathspecs that had matches with
the ignored paths...

And there is also one odd case:
$ mkdir d
$ touch d/s d/i
$ git add d/s
$ git update-index --skip-worktree d/s
$ echo d/i >.gitinore
$ git add 'd/[is]'

This outputs:
The following pathspecs only matched index entries outside the current
sparse checkout:
d/[is]
(and no warning on ignored paths).

I don't think the warning here is 100% correct, as 'd/[is]' *does* match
other files, only they are ignored. And git-add only warns about an
ignored path when the pathspec is an exact match with it (no glob
patterns) or inside it. 

So, I don't know... Perhaps should we just rephrase the sparse warning
to remove the "only" part (and don't try to avoid the double warnings)?
I'm open to any suggestions on alternative wordings or ideas :)

[1]: https://github.com/matheustavares/git/commit/5d4be321e1f69cb08b0666ffd3f8fa658e9f6953
Elijah Newren March 4, 2021, 5:21 p.m. UTC | #4
On Thu, Mar 4, 2021 at 7:23 AM Matheus Tavares
<matheus.bernardino@usp.br> wrote:
>
> Hi, Elijah
>
> I was going to send v3 a couple days ago, but there is still one issue
> that I'm having a bit of trouble with:
>
> On Wed, Feb 24, 2021 at 3:50 AM Elijah Newren <newren@gmail.com> wrote:
> >
> > Hmm...here's an interesting command sequence:
> >
> > git init lame
> > cd lame
> > mkdir baz
> > touch baz/tracked
> > git add baz/tracked
> > git update-index --skip-worktree baz/tracked
> > rm baz/tracked.  # But leave the empty directory!
> > echo baz >.gitignore
> > git add --ignore-missing --dry-run baz
> >
> >
> > Reports the following:
> > """
> > The following pathspecs only matched index entries outside the current
> > sparse checkout:
> > baz
> > hint: Disable or modify the sparsity rules if you intend to update such entries.
> > hint: Disable this message with "git config advice.updateSparsePath false"
> > The following paths are ignored by one of your .gitignore files:
> > baz
> > hint: Use -f if you really want to add them.
> > hint: Turn this message off by running
> > hint: "git config advice.addIgnoredFile false"
> > """
> >
> > That's probably okay because it does match both, but the "only
> > matched" in the first message followed by saying it matched something
> > else seems a little surprising at first.
>
> After giving this a little more thought, I got to this idea [1]. With
> this patch, git-add doesn't warn about sparse paths that are inside
> ignored dirs (it only warns about the ignored dirs themselves). If
> --force is used, then git-add recurses into the ignored directories, and
> warns about any sparse path inside them. I think this is more consistent
> with the way in which ignored dirs (and their contents) are handled with
> and without --force. And we avoid the double warning.
>
> But there is a performance penalty. The relevant code is:
>
> @@ -2029,19 +2033,23 @@ static int exclude_matches_pathspec(const char *path, int pathlen,
>                        PATHSPEC_ICASE |
>                        PATHSPEC_EXCLUDE);
>
> +       assert(matched_ignored);
> +
>         for (i = 0; i < pathspec->nr; i++) {
>                 const struct pathspec_item *item = &pathspec->items[i];
>                 int len = item->nowildcard_len;
>
> -               if (len == pathlen &&
> -                   !ps_strncmp(item, item->match, path, pathlen))
> -                       return 1;
> -               if (len > pathlen &&
> -                   item->match[pathlen] == '/' &&
> -                   !ps_strncmp(item, item->match, path, pathlen))
> -                       return 1;
> +               if (matched && matched_ignored[i])
> +                       continue;
> +
> +               if ((len == pathlen ||
> +                   (len > pathlen && item->match[pathlen] == '/')) &&
> +                   !ps_strncmp(item, item->match, path, pathlen)) {
> +                       matched = 1;
> +                       matched_ignored[i] = 1;
> +               }
>         }
> -       return 0;
> +       return matched;
>  }
>
> This function is called for each ignored path (not recursing into
> completely ignored dirs, though). Before the patch, the internal loop
> stops at the first pathspec match. But with the patch, the loop must
> continue as we need to know about all pathspecs that had matches with
> the ignored paths...
>
> And there is also one odd case:
> $ mkdir d
> $ touch d/s d/i
> $ git add d/s
> $ git update-index --skip-worktree d/s
> $ echo d/i >.gitinore
> $ git add 'd/[is]'
>
> This outputs:
> The following pathspecs only matched index entries outside the current
> sparse checkout:
> d/[is]
> (and no warning on ignored paths).
>
> I don't think the warning here is 100% correct, as 'd/[is]' *does* match
> other files, only they are ignored. And git-add only warns about an
> ignored path when the pathspec is an exact match with it (no glob
> patterns) or inside it.
>
> So, I don't know... Perhaps should we just rephrase the sparse warning
> to remove the "only" part (and don't try to avoid the double warnings)?
> I'm open to any suggestions on alternative wordings or ideas :)
>
> [1]: https://github.com/matheustavares/git/commit/5d4be321e1f69cb08b0666ffd3f8fa658e9f6953

The only thing that I think was problematic about the double warning
was the contradiction between them due to the use of "only" in the
first; if we remove that, I think printing two warnings is perfectly
fine.  So, I'm in favor of just rephrasing as you suggest.

I think it's kind of lame that git-add won't provide a warning for
ignored files when the match is via glob/pattern; if git-add didn't
end up adding anything, but an ignored file was matched, I think a
warning is appropriate.  However, I don't think your patch series
needs to address that; your series addressing sparse behavior is long
enough, and fixing the handling with ignored files could be done
separately.
Junio C Hamano March 4, 2021, 9:03 p.m. UTC | #5
Elijah Newren <newren@gmail.com> writes:

> I think it's kind of lame that git-add won't provide a warning for
> ignored files when the match is via glob/pattern; if git-add didn't
> end up adding anything, but an ignored file was matched, I think a
> warning is appropriate.

I am not so sure.  When I have this in my exclude list:

	tmp-*

and have this file in the working tree:

	tmp-000.txt

I am not sure if I want to see any such warning when I did

	$ git add "*.txt"

After all, I said that I never would want to worry about anything
that begins with "tmp-".  It would be a totally different issue if I
did

	$ git add tmp-000.txt

as I am contermanding my previous wish and saying that I care about
this particular file, even if its name begins with "tmp-".

So far, the flow of logic is simple, clear and straight-forward.

BUT.

What makes the whole thing tricky is that people don't do

	$ git add "*.txt"

The instead do

	$ git add *.txt

and "git add" cannot tell if the tmp-000.txt it sees is what the
user meant, or what the shell expanded.

If there were no shell glob expansion, then "warn only if a
glob/pattern did not match any" would have been an attractive
option, especially when we had another file in the working tree,
say, hello.txt, next to the tmp-000.txt.  Then

	$ git add "*.txt"

would add only hello.txt, and we won't see a warning about
tmp-000.txt.  But end-users will use shell expansion, i.e.

	$ git add *.txt

(1) warning against tmp-000.txt because the command line named it
    explicitly (from "git add"'s point of view, but never from the
    user's point of view) would be a disaster.  I think that is why
    your suggestion was "if git-add did not add anything then warn".
    but ...

(2) not warning, because the command line named hello.txt as well,
    i.e. "git add" added something, would make it inconsistent.
    Whether there is another file whose name ends with .txt, what
    the user typed (i.e. *.txt) to the command line is the same, and
    the exclude pattern (i.e. tmp-*) is the same, but the presence
    of an unrelated hello.txt affects if a warning is given for
    tmp-000.txt.

So, I dunno.
Matheus Tavares March 4, 2021, 9:26 p.m. UTC | #6
On Thu, Mar 4, 2021 at 2:22 PM Elijah Newren <newren@gmail.com> wrote:
>
> On Thu, Mar 4, 2021 at 7:23 AM Matheus Tavares
> <matheus.bernardino@usp.br> wrote:
> >
> > On Wed, Feb 24, 2021 at 3:50 AM Elijah Newren <newren@gmail.com> wrote:
> > >
> > > Hmm...here's an interesting command sequence:
> > >
> > > git init lame
> > > cd lame
> > > mkdir baz
> > > touch baz/tracked
> > > git add baz/tracked
> > > git update-index --skip-worktree baz/tracked
> > > rm baz/tracked.  # But leave the empty directory!
> > > echo baz >.gitignore
> > > git add --ignore-missing --dry-run baz
> > >
> > >
> > > Reports the following:
> > > """
> > > The following pathspecs only matched index entries outside the current
> > > sparse checkout:
> > > baz
> > > hint: Disable or modify the sparsity rules if you intend to update such entries.
> > > hint: Disable this message with "git config advice.updateSparsePath false"
> > > The following paths are ignored by one of your .gitignore files:
> > > baz
> > > hint: Use -f if you really want to add them.
> > > hint: Turn this message off by running
> > > hint: "git config advice.addIgnoredFile false"
> > > """
> >
> >[...] Perhaps should we just rephrase the sparse warning
> > to remove the "only" part (and don't try to avoid the double warnings)?
> > I'm open to any suggestions on alternative wordings or ideas :)
>
> The only thing that I think was problematic about the double warning
> was the contradiction between them due to the use of "only" in the
> first; if we remove that, I think printing two warnings is perfectly
> fine.  So, I'm in favor of just rephrasing as you suggest.

Ok, let's do that! I may be overthinking this, but would it be enough
to say "The following pathspecs match index entries outside the
current sparse checkout"?

This is not wrong per-se, but I think it doesn't sound as informative.
I mean, another add/rm execution could also have pathspecs that match
sparse entries, and yet no warning is displayed because they also
match non-sparse entries.

I haven't thought of a better alternative yet, but I was considering
something along "The following pathspecs won't update any paths, but
they match index entries outside the current sparse checkout". (The
idea is that "won't update any paths" include both pathspecs that only
match sparse paths and the ones that match both sparse and ignored
paths, as neither will be updated.)
Elijah Newren March 4, 2021, 10:48 p.m. UTC | #7
On Thu, Mar 4, 2021 at 1:03 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Elijah Newren <newren@gmail.com> writes:
>
> > I think it's kind of lame that git-add won't provide a warning for
> > ignored files when the match is via glob/pattern; if git-add didn't
> > end up adding anything, but an ignored file was matched, I think a
> > warning is appropriate.
>
> I am not so sure.  When I have this in my exclude list:
>
>         tmp-*
>
> and have this file in the working tree:
>
>         tmp-000.txt
>
> I am not sure if I want to see any such warning when I did
>
>         $ git add "*.txt"
>
> After all, I said that I never would want to worry about anything
> that begins with "tmp-".  It would be a totally different issue if I
> did
>
>         $ git add tmp-000.txt
>
> as I am contermanding my previous wish and saying that I care about
> this particular file, even if its name begins with "tmp-".
>
> So far, the flow of logic is simple, clear and straight-forward.
>
> BUT.
>
> What makes the whole thing tricky is that people don't do
>
>         $ git add "*.txt"
>
> The instead do
>
>         $ git add *.txt
>
> and "git add" cannot tell if the tmp-000.txt it sees is what the
> user meant, or what the shell expanded.
>
> If there were no shell glob expansion, then "warn only if a
> glob/pattern did not match any" would have been an attractive
> option, especially when we had another file in the working tree,
> say, hello.txt, next to the tmp-000.txt.  Then
>
>         $ git add "*.txt"
>
> would add only hello.txt, and we won't see a warning about
> tmp-000.txt.  But end-users will use shell expansion, i.e.
>
>         $ git add *.txt
>
> (1) warning against tmp-000.txt because the command line named it
>     explicitly (from "git add"'s point of view, but never from the
>     user's point of view) would be a disaster.  I think that is why
>     your suggestion was "if git-add did not add anything then warn".
>     but ...
>
> (2) not warning, because the command line named hello.txt as well,
>     i.e. "git add" added something, would make it inconsistent.
>     Whether there is another file whose name ends with .txt, what
>     the user typed (i.e. *.txt) to the command line is the same, and
>     the exclude pattern (i.e. tmp-*) is the same, but the presence
>     of an unrelated hello.txt affects if a warning is given for
>     tmp-000.txt.
>
> So, I dunno.

Fair enough.  All the more reason to leave it out of Matheus' patch
series, then.  :-)
diff mbox series

Patch

diff --git a/Documentation/config/advice.txt b/Documentation/config/advice.txt
index acbd0c09aa..d53eafa00b 100644
--- a/Documentation/config/advice.txt
+++ b/Documentation/config/advice.txt
@@ -119,4 +119,7 @@  advice.*::
 	addEmptyPathspec::
 		Advice shown if a user runs the add command without providing
 		the pathspec parameter.
+	updateSparsePath::
+		Advice shown if the pathspec given to linkgit:git-add[1] only
+		matches index entries outside the current sparse-checkout.
 --
diff --git a/advice.c b/advice.c
index 164742305f..cf22c1a6e5 100644
--- a/advice.c
+++ b/advice.c
@@ -2,6 +2,7 @@ 
 #include "config.h"
 #include "color.h"
 #include "help.h"
+#include "string-list.h"
 
 int advice_fetch_show_forced_updates = 1;
 int advice_push_update_rejected = 1;
@@ -136,6 +137,7 @@  static struct {
 	[ADVICE_STATUS_HINTS]				= { "statusHints", 1 },
 	[ADVICE_STATUS_U_OPTION]			= { "statusUoption", 1 },
 	[ADVICE_SUBMODULE_ALTERNATE_ERROR_STRATEGY_DIE] = { "submoduleAlternateErrorStrategyDie", 1 },
+	[ADVICE_UPDATE_SPARSE_PATH]			= { "updateSparsePath", 1 },
 	[ADVICE_WAITING_FOR_EDITOR]			= { "waitingForEditor", 1 },
 };
 
@@ -284,6 +286,23 @@  void NORETURN die_conclude_merge(void)
 	die(_("Exiting because of unfinished merge."));
 }
 
+void advise_on_updating_sparse_paths(struct string_list *pathspec_list)
+{
+	struct string_list_item *item;
+
+	if (!pathspec_list->nr)
+		return;
+
+	fprintf(stderr, _("The following pathspecs only matched index entries outside the current\n"
+			  "sparse checkout:\n"));
+	for_each_string_list_item(item, pathspec_list)
+		fprintf(stderr, "%s\n", item->string);
+
+	advise_if_enabled(ADVICE_UPDATE_SPARSE_PATH,
+			  _("Disable or modify the sparsity rules if you intend to update such entries."));
+
+}
+
 void detach_advice(const char *new_name)
 {
 	const char *fmt =
diff --git a/advice.h b/advice.h
index bc2432980a..bd26c385d0 100644
--- a/advice.h
+++ b/advice.h
@@ -3,6 +3,8 @@ 
 
 #include "git-compat-util.h"
 
+struct string_list;
+
 extern int advice_fetch_show_forced_updates;
 extern int advice_push_update_rejected;
 extern int advice_push_non_ff_current;
@@ -71,6 +73,7 @@  extern int advice_add_empty_pathspec;
 	ADVICE_STATUS_HINTS,
 	ADVICE_STATUS_U_OPTION,
 	ADVICE_SUBMODULE_ALTERNATE_ERROR_STRATEGY_DIE,
+	ADVICE_UPDATE_SPARSE_PATH,
 	ADVICE_WAITING_FOR_EDITOR,
 };
 
@@ -92,6 +95,7 @@  void advise_if_enabled(enum advice_type type, const char *advice, ...);
 int error_resolve_conflict(const char *me);
 void NORETURN die_resolve_conflict(const char *me);
 void NORETURN die_conclude_merge(void);
+void advise_on_updating_sparse_paths(struct string_list *pathspec_list);
 void detach_advice(const char *new_name);
 
 #endif /* ADVICE_H */
diff --git a/builtin/add.c b/builtin/add.c
index e15b25a623..fde6462850 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -177,24 +177,43 @@  static char *prune_directory(struct dir_struct *dir, struct pathspec *pathspec,
 			*dst++ = entry;
 	}
 	dir->nr = dst - dir->entries;
-	add_pathspec_matches_against_index(pathspec, &the_index, seen, 0);
+	add_pathspec_matches_against_index(pathspec, &the_index, seen, 1);
 	return seen;
 }
 
-static void refresh(int verbose, const struct pathspec *pathspec)
+static int refresh(int verbose, const struct pathspec *pathspec)
 {
 	char *seen;
-	int i;
+	int i, ret = 0;
+	char *skip_worktree_seen = NULL;
+	struct string_list only_match_skip_worktree = STRING_LIST_INIT_NODUP;
+	int flags = REFRESH_DONT_MARK_SPARSE_MATCHES |
+		    (verbose ? REFRESH_IN_PORCELAIN : REFRESH_QUIET);
 
 	seen = xcalloc(pathspec->nr, 1);
-	refresh_index(&the_index, verbose ? REFRESH_IN_PORCELAIN : REFRESH_QUIET,
-		      pathspec, seen, _("Unstaged changes after refreshing the index:"));
+	refresh_index(&the_index, flags, pathspec, seen,
+		      _("Unstaged changes after refreshing the index:"));
 	for (i = 0; i < pathspec->nr; i++) {
-		if (!seen[i])
-			die(_("pathspec '%s' did not match any files"),
-			    pathspec->items[i].original);
+		if (!seen[i]) {
+			if (matches_skip_worktree(pathspec, i, &skip_worktree_seen)) {
+				string_list_append(&only_match_skip_worktree,
+						   pathspec->items[i].original);
+			} else {
+				die(_("pathspec '%s' did not match any files"),
+				    pathspec->items[i].original);
+			}
+		}
+	}
+
+	if (only_match_skip_worktree.nr) {
+		advise_on_updating_sparse_paths(&only_match_skip_worktree);
+		ret = 1;
 	}
+
 	free(seen);
+	free(skip_worktree_seen);
+	string_list_clear(&only_match_skip_worktree, 0);
+	return ret;
 }
 
 int run_add_interactive(const char *revision, const char *patch_mode,
@@ -570,15 +589,17 @@  int cmd_add(int argc, const char **argv, const char *prefix)
 	}
 
 	if (refresh_only) {
-		refresh(verbose, &pathspec);
+		exit_status |= refresh(verbose, &pathspec);
 		goto finish;
 	}
 
 	if (pathspec.nr) {
 		int i;
+		char *skip_worktree_seen = NULL;
+		struct string_list only_match_skip_worktree = STRING_LIST_INIT_NODUP;
 
 		if (!seen)
-			seen = find_pathspecs_matching_against_index(&pathspec, &the_index, 0);
+			seen = find_pathspecs_matching_against_index(&pathspec, &the_index, 1);
 
 		/*
 		 * file_exists() assumes exact match
@@ -592,12 +613,24 @@  int cmd_add(int argc, const char **argv, const char *prefix)
 
 		for (i = 0; i < pathspec.nr; i++) {
 			const char *path = pathspec.items[i].match;
+
 			if (pathspec.items[i].magic & PATHSPEC_EXCLUDE)
 				continue;
-			if (!seen[i] && path[0] &&
-			    ((pathspec.items[i].magic &
-			      (PATHSPEC_GLOB | PATHSPEC_ICASE)) ||
-			     !file_exists(path))) {
+			if (seen[i])
+				continue;
+
+			if (matches_skip_worktree(&pathspec, i, &skip_worktree_seen)) {
+				string_list_append(&only_match_skip_worktree,
+						   pathspec.items[i].original);
+				continue;
+			}
+
+			/* Don't complain at 'git add .' inside empty repo. */
+			if (!path[0])
+				continue;
+
+			if ((pathspec.items[i].magic & (PATHSPEC_GLOB | PATHSPEC_ICASE)) ||
+			    !file_exists(path)) {
 				if (ignore_missing) {
 					int dtype = DT_UNKNOWN;
 					if (is_excluded(&dir, &the_index, path, &dtype))
@@ -608,7 +641,16 @@  int cmd_add(int argc, const char **argv, const char *prefix)
 					    pathspec.items[i].original);
 			}
 		}
+
+
+		if (only_match_skip_worktree.nr) {
+			advise_on_updating_sparse_paths(&only_match_skip_worktree);
+			exit_status = 1;
+		}
+
 		free(seen);
+		free(skip_worktree_seen);
+		string_list_clear(&only_match_skip_worktree, 0);
 	}
 
 	plug_bulk_checkin();
diff --git a/pathspec.c b/pathspec.c
index e5e6b7458d..61f294fed5 100644
--- a/pathspec.c
+++ b/pathspec.c
@@ -62,6 +62,21 @@  char *find_pathspecs_matching_against_index(const struct pathspec *pathspec,
 	return seen;
 }
 
+char *find_pathspecs_matching_skip_worktree(const struct pathspec *pathspec)
+{
+	struct index_state *istate = the_repository->index;
+	char *seen = xcalloc(pathspec->nr, 1);
+	int i;
+
+	for (i = 0; i < istate->cache_nr; i++) {
+		struct cache_entry *ce = istate->cache[i];
+		if (ce_skip_worktree(ce))
+		    ce_path_match(istate, ce, pathspec, seen);
+	}
+
+	return seen;
+}
+
 /*
  * Magic pathspec
  *
diff --git a/pathspec.h b/pathspec.h
index 8202882ecd..f591ba625c 100644
--- a/pathspec.h
+++ b/pathspec.h
@@ -155,6 +155,14 @@  void add_pathspec_matches_against_index(const struct pathspec *pathspec,
 char *find_pathspecs_matching_against_index(const struct pathspec *pathspec,
 					    const struct index_state *istate,
 					    int ignore_skip_worktree);
+char *find_pathspecs_matching_skip_worktree(const struct pathspec *pathspec);
+static inline int matches_skip_worktree(const struct pathspec *pathspec,
+					int item, char **seen_ptr)
+{
+	if (!*seen_ptr)
+		*seen_ptr = find_pathspecs_matching_skip_worktree(pathspec);
+	return (*seen_ptr)[item];
+}
 int match_pathspec_attrs(const struct index_state *istate,
 			 const char *name, int namelen,
 			 const struct pathspec_item *item);
diff --git a/t/t3705-add-sparse-checkout.sh b/t/t3705-add-sparse-checkout.sh
index 6781620297..fdfd8b085e 100755
--- a/t/t3705-add-sparse-checkout.sh
+++ b/t/t3705-add-sparse-checkout.sh
@@ -36,10 +36,26 @@  setup_gitignore () {
 	EOF
 }
 
+test_expect_success 'setup' '
+	cat >sparse_error_header <<-EOF &&
+	The following pathspecs only matched index entries outside the current
+	sparse checkout:
+	EOF
+
+	cat >sparse_hint <<-EOF &&
+	hint: Disable or modify the sparsity rules if you intend to update such entries.
+	hint: Disable this message with "git config advice.updateSparsePath false"
+	EOF
+
+	echo sparse_entry | cat sparse_error_header - >sparse_entry_error &&
+	cat sparse_entry_error sparse_hint >error_and_hint
+'
+
 test_expect_success 'git add does not remove sparse entries' '
 	setup_sparse_entry &&
 	rm sparse_entry &&
-	git add sparse_entry &&
+	test_must_fail git add sparse_entry 2>stderr &&
+	test_i18ncmp error_and_hint stderr &&
 	test_sparse_entry_unchanged
 '
 
@@ -47,7 +63,8 @@  test_expect_success 'git add -A does not remove sparse entries' '
 	setup_sparse_entry &&
 	rm sparse_entry &&
 	setup_gitignore &&
-	git add -A &&
+	git add -A 2>stderr &&
+	test_must_be_empty stderr &&
 	test_sparse_entry_unchanged
 '
 
@@ -55,7 +72,13 @@  test_expect_success 'git add . does not remove sparse entries' '
 	setup_sparse_entry &&
 	rm sparse_entry &&
 	setup_gitignore &&
-	git add . &&
+	test_must_fail git add . 2>stderr &&
+
+	cat sparse_error_header >expect &&
+	echo . >>expect &&
+	cat sparse_hint >>expect &&
+
+	test_i18ncmp expect stderr &&
 	test_sparse_entry_unchanged
 '
 
@@ -64,7 +87,8 @@  do
 	test_expect_success "git add${opt:+ $opt} does not update sparse entries" '
 		setup_sparse_entry &&
 		echo modified >sparse_entry &&
-		git add $opt sparse_entry &&
+		test_must_fail git add $opt sparse_entry 2>stderr &&
+		test_i18ncmp error_and_hint stderr &&
 		test_sparse_entry_unchanged
 	'
 done
@@ -73,14 +97,16 @@  test_expect_success 'git add --refresh does not update sparse entries' '
 	setup_sparse_entry &&
 	git ls-files --debug sparse_entry | grep mtime >before &&
 	test-tool chmtime -60 sparse_entry &&
-	git add --refresh sparse_entry &&
+	test_must_fail git add --refresh sparse_entry 2>stderr &&
+	test_i18ncmp error_and_hint stderr &&
 	git ls-files --debug sparse_entry | grep mtime >after &&
 	test_cmp before after
 '
 
 test_expect_success 'git add --chmod does not update sparse entries' '
 	setup_sparse_entry &&
-	git add --chmod=+x sparse_entry &&
+	test_must_fail git add --chmod=+x sparse_entry 2>stderr &&
+	test_i18ncmp error_and_hint stderr &&
 	test_sparse_entry_unchanged &&
 	! test -x sparse_entry
 '
@@ -89,8 +115,41 @@  test_expect_success 'git add --renormalize does not update sparse entries' '
 	test_config core.autocrlf false &&
 	setup_sparse_entry "LINEONE\r\nLINETWO\r\n" &&
 	echo "sparse_entry text=auto" >.gitattributes &&
-	git add --renormalize sparse_entry &&
+	test_must_fail git add --renormalize sparse_entry 2>stderr &&
+	test_i18ncmp error_and_hint stderr &&
 	test_sparse_entry_unchanged
 '
 
+test_expect_success 'git add --dry-run --ignore-missing warn on sparse path' '
+	setup_sparse_entry &&
+	rm sparse_entry &&
+	test_must_fail git add --dry-run --ignore-missing sparse_entry 2>stderr &&
+	test_i18ncmp error_and_hint stderr &&
+	test_sparse_entry_unchanged
+'
+
+test_expect_success 'do not advice about sparse entries when they do not match the pathspec' '
+	setup_sparse_entry &&
+	test_must_fail git add nonexistent 2>stderr &&
+	test_i18ngrep "fatal: pathspec .nonexistent. did not match any files" stderr &&
+	test_i18ngrep ! "The following pathspecs only matched index entries" stderr
+'
+
+test_expect_success 'do not warn when pathspec matches dense entries' '
+	setup_sparse_entry &&
+	echo modified >sparse_entry &&
+	>dense_entry &&
+	git add "*_entry" 2>stderr &&
+	test_must_be_empty stderr &&
+	test_sparse_entry_unchanged &&
+	git ls-files --error-unmatch dense_entry
+'
+
+test_expect_success 'add obeys advice.updateSparsePath' '
+	setup_sparse_entry &&
+	test_must_fail git -c advice.updateSparsePath=false add sparse_entry 2>stderr &&
+	test_i18ncmp sparse_entry_error stderr
+
+'
+
 test_done