]> git.ipfire.org Git - thirdparty/git.git/commitdiff
Merge branch 'mm/test-grep-lint'
authorJunio C Hamano <gitster@pobox.com>
Sun, 19 Jul 2026 17:42:17 +0000 (10:42 -0700)
committerJunio C Hamano <gitster@pobox.com>
Sun, 19 Jul 2026 17:42:18 +0000 (10:42 -0700)
The test suite has been updated to use the 'test_grep' helper instead
of bare 'grep' for test assertions, allowing file contents to be
printed on failure for easier debugging.  A new 'greplint' linter has
been introduced to detect and prevent new bare 'grep' assertions from
being added to the test suite.

* mm/test-grep-lint:
  t: add greplint to detect bare grep assertions
  t: convert grep assertions to test_grep
  t: fix Lexer line count for $() inside double-quoted strings
  t: extract chainlint's parser into shared module
  t: fix grep assertions missing file arguments
  t/README: document test_grep helper

47 files changed:
1  2 
t/README
t/for-each-ref-tests.sh
t/lib-shell-parser.pl
t/t0001-init.sh
t/t0012-help.sh
t/t0021-conversion.sh
t/t0040-parse-options.sh
t/t0450-txt-doc-vs-help.sh
t/t0610-reftable-basics.sh
t/t1007-hash-object.sh
t/t1300-config.sh
t/t1400-update-ref.sh
t/t1403-show-ref.sh
t/t1410-reflog.sh
t/t1502-rev-parse-parseopt.sh
t/t1800-hook.sh
t/t3200-branch.sh
t/t3420-rebase-autostash.sh
t/t3903-stash.sh
t/t4067-diff-partial-clone.sh
t/t4141-apply-too-large.sh
t/t4200-rerere.sh
t/t4202-log.sh
t/t4216-log-bloom.sh
t/t5304-prune.sh
t/t5310-pack-bitmaps.sh
t/t5328-commit-graph-64bit-time.sh
t/t5510-fetch.sh
t/t5512-ls-remote.sh
t/t5529-push-errors.sh
t/t5551-http-fetch-smart.sh
t/t5616-partial-clone.sh
t/t6040-tracking-info.sh
t/t6120-describe.sh
t/t6422-merge-rename-corner-cases.sh
t/t6500-gc.sh
t/t6600-test-reach.sh
t/t7030-verify-tag.sh
t/t7450-bad-git-dotfiles.sh
t/t7508-status.sh
t/t7510-signed-commit.sh
t/t7527-builtin-fsmonitor.sh
t/t7600-merge.sh
t/t7800-difftool.sh
t/t7810-grep.sh
t/t7900-maintenance.sh
t/t9902-completion.sh

diff --cc t/README
Simple merge
index b95e5b6ca05a4483a675514cdce48dc51ee35f9c,6b359d940e926789ffa2ae70eb2027a277075706..b1a10dd9332b3998a5da9d4c5414ef51071f33e4
@@@ -522,8 -522,8 +522,8 @@@ test_expect_success 'Verify descending 
  '
  
  test_expect_success 'Give help even with invalid sort atoms' '
 -      test_expect_code 129 ${git_for_each_ref} --sort=bogus -h >actual 2>&1 &&
 +      ${git_for_each_ref} --sort=bogus -h >actual 2>&1 &&
-       grep "^usage: ${git_for_each_ref}" actual
+       test_grep "^usage: ${git_for_each_ref}" actual
  '
  
  cat >expected <<\EOF
index 0000000000000000000000000000000000000000,17fbf461b10204cffd37d594f4df61dc2cad88ec..d78e18510e4143777f7b015619e86fd68633a78d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,534 +1,534 @@@
 -# `1 + 2`, which embeds whitespace, is scanned as three token in shell, as well.
+ # Copyright (c) 2021-2022 Eric Sunshine <sunshine@sunshineco.com>
+ #
+ # Shared shell script parser for test lint tools. Provides Lexer,
+ # ShellParser, and ScriptParser. Subclass ScriptParser and override
+ # check_test() to implement lint checks.
+ use strict;
+ use warnings;
+ # Lexer tokenizes POSIX shell scripts. It is roughly modeled after section 2.3
+ # "Token Recognition" of POSIX chapter 2 "Shell Command Language". Although
+ # similar to lexical analyzers for other languages, this one differs in a few
+ # substantial ways due to quirks of the shell command language.
+ #
+ # For instance, in many languages, newline is just whitespace like space or
+ # TAB, but in shell a newline is a command separator, thus a distinct lexical
+ # token. A newline is significant and returned as a distinct token even at the
+ # end of a shell comment.
+ #
+ # In other languages, `1+2` would typically be scanned as three tokens
+ # (`1`, `+`, and `2`), but in shell it is a single token. However, the similar
++# `1 + 2`, which embeds whitepaces, is scanned as three token in shell, as well.
+ # In shell, several characters with special meaning lose that meaning when not
+ # surrounded by whitespace. For instance, the negation operator `!` is special
+ # when standing alone surrounded by whitespace; whereas in `foo!uucp` it is
+ # just a plain character in the longer token "foo!uucp". In many other
+ # languages, `"string"/foo:'string'` might be scanned as five tokens ("string",
+ # `/`, `foo`, `:`, and 'string'), but in shell, it is just a single token.
+ #
+ # The lexical analyzer for the shell command language is also somewhat unusual
+ # in that it recursively invokes the parser to handle the body of `$(...)`
+ # expressions which can contain arbitrary shell code. Such expressions may be
+ # encountered both inside and outside of double-quoted strings.
+ #
+ # The lexical analyzer is responsible for consuming shell here-doc bodies which
+ # extend from the line following a `<<TAG` operator until a line consisting
+ # solely of `TAG`. Here-doc consumption begins when a newline is encountered.
+ # It is legal for multiple here-doc `<<TAG` operators to be present on a single
+ # line, in which case their bodies must be present one following the next, and
+ # are consumed in the (left-to-right) order the `<<TAG` operators appear on the
+ # line. A special complication is that the bodies of all here-docs must be
+ # consumed when the newline is encountered even if the parse context depth has
+ # changed. For instance, in `cat <<A && x=$(cat <<B &&\n`, bodies of here-docs
+ # "A" and "B" must be consumed even though "A" was introduced outside the
+ # recursive parse context in which "B" was introduced and in which the newline
+ # is encountered.
+ package Lexer;
+ sub new {
+       my ($class, $parser, $s) = @_;
+       bless {
+               parser => $parser,
+               buff => $s,
+               lineno => 1,
+               heretags => []
+       } => $class;
+ }
+ sub scan_heredoc_tag {
+       my $self = shift @_;
+       ${$self->{buff}} =~ /\G(-?)/gc;
+       my $indented = $1;
+       my $token = $self->scan_token();
+       return "<<$indented" unless $token;
+       my $tag = $token->[0];
+       $tag =~ s/['"\\]//g;
+       $$token[0] = $indented ? "\t$tag" : "$tag";
+       push(@{$self->{heretags}}, $token);
+       return "<<$indented$tag";
+ }
+ sub scan_op {
+       my ($self, $c) = @_;
+       my $b = $self->{buff};
+       return $c unless $$b =~ /\G(.)/sgc;
+       my $cc = $c . $1;
+       return scan_heredoc_tag($self) if $cc eq '<<';
+       return $cc if $cc =~ /^(?:&&|\|\||>>|;;|<&|>&|<>|>\|)$/;
+       pos($$b)--;
+       return $c;
+ }
+ sub scan_sqstring {
+       my $self = shift @_;
+       ${$self->{buff}} =~ /\G([^']*'|.*\z)/sgc;
+       my $s = $1;
+       $self->{lineno} += () = $s =~ /\n/sg;
+       return "'" . $s;
+ }
+ sub scan_dqstring {
+       my $self = shift @_;
+       my $b = $self->{buff};
+       my $s = '"';
+       while (1) {
+               # Slurp non-special characters; count newlines here because
+               # newlines inside $() are already counted by the recursive parse.
+               if ($$b =~ /\G([^"\$\\]+)/gc) {
+                       $s .= $1;
+                       $self->{lineno} += $1 =~ tr/\n//;
+               }
+               # handle special characters
+               last unless $$b =~ /\G(.)/sgc;
+               my $c = $1;
+               $s .= '"', last if $c eq '"';
+               $s .= '$' . $self->scan_dollar(), next if $c eq '$';
+               if ($c eq '\\') {
+                       $s .= '\\', last unless $$b =~ /\G(.)/sgc;
+                       $c = $1;
+                       $self->{lineno}++, next if $c eq "\n"; # line splice
+                       # backslash escapes only $, `, ", \ in dq-string
+                       $s .= '\\' unless $c =~ /^[\$`"\\]$/;
+                       $s .= $c;
+                       next;
+               }
+               die("internal error scanning dq-string '$c'\n");
+       }
+       return $s;
+ }
+ sub scan_balanced {
+       my ($self, $c1, $c2) = @_;
+       my $b = $self->{buff};
+       my $depth = 1;
+       my $s = $c1;
+       while ($$b =~ /\G([^\Q$c1$c2\E]*(?:[\Q$c1$c2\E]|\z))/gc) {
+               $s .= $1;
+               $depth++, next if $s =~ /\Q$c1\E$/;
+               $depth--;
+               last if $depth == 0;
+       }
+       $self->{lineno} += () = $s =~ /\n/sg;
+       return $s;
+ }
+ sub scan_subst {
+       my $self = shift @_;
+       my @tokens = $self->{parser}->parse(qr/^\)$/);
+       $self->{parser}->next_token(); # closing ")"
+       return @tokens;
+ }
+ sub scan_dollar {
+       my $self = shift @_;
+       my $b = $self->{buff};
+       return $self->scan_balanced('(', ')') if $$b =~ /\G\((?=\()/gc; # $((...))
+       return '(' . join(' ', map {$_->[0]} $self->scan_subst()) . ')' if $$b =~ /\G\(/gc; # $(...)
+       return $self->scan_balanced('{', '}') if $$b =~ /\G\{/gc; # ${...}
+       return $1 if $$b =~ /\G(\w+)/gc; # $var
+       return $1 if $$b =~ /\G([@*#?$!0-9-])/gc; # $*, $1, $$, etc.
+       return '';
+ }
+ sub swallow_heredocs {
+       my $self = shift @_;
+       my $b = $self->{buff};
+       my $tags = $self->{heretags};
+       while (my $tag = shift @$tags) {
+               my $start = pos($$b);
+               my $indent = $$tag[0] =~ s/^\t// ? '\\s*' : '';
+               $$b =~ /(?:\G|\n)$indent\Q$$tag[0]\E(?:\n|\z)/gc;
+               if (pos($$b) > $start) {
+                       my $body = substr($$b, $start, pos($$b) - $start);
+                       $self->{parser}->{heredocs}->{$$tag[0]} = {
+                               content => substr($body, 0, length($body) - length($&)),
+                               start_line => $self->{lineno},
+                       };
+                       $self->{lineno} += () = $body =~ /\n/sg;
+                       next;
+               }
+               push(@{$self->{parser}->{problems}}, ['HEREDOC', $tag]);
+               $$b =~ /(?:\G|\n).*\z/gc; # consume rest of input
+               my $body = substr($$b, $start, pos($$b) - $start);
+               $self->{lineno} += () = $body =~ /\n/sg;
+               last;
+       }
+ }
+ sub scan_token {
+       my $self = shift @_;
+       my $b = $self->{buff};
+       my $token = '';
+       my ($start, $startln);
+ RESTART:
+       $startln = $self->{lineno};
+       $$b =~ /\G[ \t]+/gc; # skip whitespace (but not newline)
+       $start = pos($$b) || 0;
+       $self->{lineno}++, return ["\n", $start, pos($$b), $startln, $startln] if $$b =~ /\G#[^\n]*(?:\n|\z)/gc; # comment
+       while (1) {
+               # slurp up non-special characters
+               $token .= $1 if $$b =~ /\G([^\\;&|<>(){}'"\$\s]+)/gc;
+               # handle special characters
+               last unless $$b =~ /\G(.)/sgc;
+               my $c = $1;
+               pos($$b)--, last if $c =~ /^[ \t]$/; # whitespace ends token
+               pos($$b)--, last if length($token) && $c =~ /^[;&|<>(){}\n]$/;
+               $token .= $self->scan_sqstring(), next if $c eq "'";
+               $token .= $self->scan_dqstring(), next if $c eq '"';
+               $token .= $c . $self->scan_dollar(), next if $c eq '$';
+               $self->{lineno}++, $self->swallow_heredocs(), $token = $c, last if $c eq "\n";
+               $token = $self->scan_op($c), last if $c =~ /^[;&|<>]$/;
+               $token = $c, last if $c =~ /^[(){}]$/;
+               if ($c eq '\\') {
+                       $token .= '\\', last unless $$b =~ /\G(.)/sgc;
+                       $c = $1;
+                       $self->{lineno}++, next if $c eq "\n" && length($token); # line splice
+                       $self->{lineno}++, goto RESTART if $c eq "\n"; # line splice
+                       $token .= '\\' . $c;
+                       next;
+               }
+               die("internal error scanning character '$c'\n");
+       }
+       return length($token) ? [$token, $start, pos($$b), $startln, $self->{lineno}] : undef;
+ }
+ # ShellParser parses POSIX shell scripts (with minor extensions for Bash). It
+ # is a recursive descent parser very roughly modeled after section 2.10 "Shell
+ # Grammar" of POSIX chapter 2 "Shell Command Language".
+ package ShellParser;
+ sub new {
+       my ($class, $s) = @_;
+       my $self = bless {
+               buff => [],
+               stop => [],
+               output => [],
+               heredocs => {},
+               insubshell => 0,
+       } => $class;
+       $self->{lexer} = Lexer->new($self, $s);
+       return $self;
+ }
+ sub next_token {
+       my $self = shift @_;
+       return pop(@{$self->{buff}}) if @{$self->{buff}};
+       return $self->{lexer}->scan_token();
+ }
+ sub untoken {
+       my $self = shift @_;
+       push(@{$self->{buff}}, @_);
+ }
+ sub peek {
+       my $self = shift @_;
+       my $token = $self->next_token();
+       return undef unless defined($token);
+       $self->untoken($token);
+       return $token;
+ }
+ sub stop_at {
+       my ($self, $token) = @_;
+       return 1 unless defined($token);
+       my $stop = ${$self->{stop}}[-1] if @{$self->{stop}};
+       return defined($stop) && $token->[0] =~ $stop;
+ }
+ sub expect {
+       my ($self, $expect) = @_;
+       my $token = $self->next_token();
+       return $token if defined($token) && $token->[0] eq $expect;
+       push(@{$self->{output}}, "?!ERR?! expected '$expect' but found '" . (defined($token) ? $token->[0] : "<end-of-input>") . "'\n");
+       $self->untoken($token) if defined($token);
+       return ();
+ }
+ sub optional_newlines {
+       my $self = shift @_;
+       my @tokens;
+       while (my $token = $self->peek()) {
+               last unless $token->[0] eq "\n";
+               push(@tokens, $self->next_token());
+       }
+       return @tokens;
+ }
+ sub parse_group {
+       my $self = shift @_;
+       return ($self->parse(qr/^}$/),
+               $self->expect('}'));
+ }
+ sub parse_subshell {
+       my $self = shift @_;
+       $self->{insubshell}++;
+       my @tokens = ($self->parse(qr/^\)$/),
+                     $self->expect(')'));
+       $self->{insubshell}--;
+       return @tokens;
+ }
+ sub parse_case_pattern {
+       my $self = shift @_;
+       my @tokens;
+       while (defined(my $token = $self->next_token())) {
+               push(@tokens, $token);
+               last if $token->[0] eq ')';
+       }
+       return @tokens;
+ }
+ sub parse_case {
+       my $self = shift @_;
+       my @tokens;
+       push(@tokens,
+            $self->next_token(), # subject
+            $self->optional_newlines(),
+            $self->expect('in'),
+            $self->optional_newlines());
+       while (1) {
+               my $token = $self->peek();
+               last unless defined($token) && $token->[0] ne 'esac';
+               push(@tokens,
+                    $self->parse_case_pattern(),
+                    $self->optional_newlines(),
+                    $self->parse(qr/^(?:;;|esac)$/)); # item body
+               $token = $self->peek();
+               last unless defined($token) && $token->[0] ne 'esac';
+               push(@tokens,
+                    $self->expect(';;'),
+                    $self->optional_newlines());
+       }
+       push(@tokens, $self->expect('esac'));
+       return @tokens;
+ }
+ sub parse_for {
+       my $self = shift @_;
+       my @tokens;
+       push(@tokens,
+            $self->next_token(), # variable
+            $self->optional_newlines());
+       my $token = $self->peek();
+       if (defined($token) && $token->[0] eq 'in') {
+               push(@tokens,
+                    $self->expect('in'),
+                    $self->optional_newlines());
+       }
+       push(@tokens,
+            $self->parse(qr/^do$/), # items
+            $self->expect('do'),
+            $self->optional_newlines(),
+            $self->parse_loop_body(),
+            $self->expect('done'));
+       return @tokens;
+ }
+ sub parse_if {
+       my $self = shift @_;
+       my @tokens;
+       while (1) {
+               push(@tokens,
+                    $self->parse(qr/^then$/), # if/elif condition
+                    $self->expect('then'),
+                    $self->optional_newlines(),
+                    $self->parse(qr/^(?:elif|else|fi)$/)); # if/elif body
+               my $token = $self->peek();
+               last unless defined($token) && $token->[0] eq 'elif';
+               push(@tokens, $self->expect('elif'));
+       }
+       my $token = $self->peek();
+       if (defined($token) && $token->[0] eq 'else') {
+               push(@tokens,
+                    $self->expect('else'),
+                    $self->optional_newlines(),
+                    $self->parse(qr/^fi$/)); # else body
+       }
+       push(@tokens, $self->expect('fi'));
+       return @tokens;
+ }
+ sub parse_loop_body {
+       my $self = shift @_;
+       return $self->parse(qr/^done$/);
+ }
+ sub parse_loop {
+       my $self = shift @_;
+       return ($self->parse(qr/^do$/), # condition
+               $self->expect('do'),
+               $self->optional_newlines(),
+               $self->parse_loop_body(),
+               $self->expect('done'));
+ }
+ sub parse_func {
+       my $self = shift @_;
+       return ($self->expect('('),
+               $self->expect(')'),
+               $self->optional_newlines(),
+               $self->parse_cmd()); # body
+ }
+ sub parse_bash_array_assignment {
+       my $self = shift @_;
+       my @tokens = $self->expect('(');
+       while (defined(my $token = $self->next_token())) {
+               push(@tokens, $token);
+               last if $token->[0] eq ')';
+       }
+       return @tokens;
+ }
+ my %compound = (
+       '{' => \&parse_group,
+       '(' => \&parse_subshell,
+       'case' => \&parse_case,
+       'for' => \&parse_for,
+       'if' => \&parse_if,
+       'until' => \&parse_loop,
+       'while' => \&parse_loop);
+ sub parse_cmd {
+       my $self = shift @_;
+       my $cmd = $self->next_token();
+       return () unless defined($cmd);
+       return $cmd if $cmd->[0] eq "\n";
+       my $token;
+       my @tokens = $cmd;
+       if ($cmd->[0] eq '!') {
+               push(@tokens, $self->parse_cmd());
+               return @tokens;
+       } elsif (my $f = $compound{$cmd->[0]}) {
+               push(@tokens, $self->$f());
+       } elsif (defined($token = $self->peek()) && $token->[0] eq '(') {
+               if ($cmd->[0] !~ /\w=$/) {
+                       push(@tokens, $self->parse_func());
+                       return @tokens;
+               }
+               my @array = $self->parse_bash_array_assignment();
+               $tokens[-1]->[0] .= join(' ', map {$_->[0]} @array);
+               $tokens[-1]->[2] = $array[$#array][2] if @array;
+       }
+       while (defined(my $token = $self->next_token())) {
+               $self->untoken($token), last if $self->stop_at($token);
+               push(@tokens, $token);
+               last if $token->[0] =~ /^(?:[;&\n|]|&&|\|\|)$/;
+       }
+       push(@tokens, $self->next_token()) if $tokens[-1]->[0] ne "\n" && defined($token = $self->peek()) && $token->[0] eq "\n";
+       return @tokens;
+ }
+ sub accumulate {
+       my ($self, $tokens, $cmd) = @_;
+       push(@$tokens, @$cmd);
+ }
+ sub parse {
+       my ($self, $stop) = @_;
+       push(@{$self->{stop}}, $stop);
+       goto DONE if $self->stop_at($self->peek());
+       my @tokens;
+       while (my @cmd = $self->parse_cmd()) {
+               $self->accumulate(\@tokens, \@cmd);
+               last if $self->stop_at($self->peek());
+       }
+ DONE:
+       pop(@{$self->{stop}});
+       return @tokens;
+ }
+ # ScriptParser is a subclass of ShellParser which identifies individual test
+ # definitions within test scripts and passes each test body to check_test().
+ # ScriptParser detects test definitions not only at the top-level of test
+ # scripts but also within compound commands such as loops and function
+ # definitions.
+ package ScriptParser;
+ our @ISA = ('ShellParser');
+ sub new {
+       my $class = shift @_;
+       my $self = $class->SUPER::new(@_);
+       $self->{ntests} = 0;
+       $self->{nerrs} = 0;
+       return $self;
+ }
+ # extract the raw content of a token, which may be a single string or a
+ # composition of multiple strings and non-string character runs; for instance,
+ # `"test body"` unwraps to `test body`; `word"a b"42'c d'` to `worda b42c d`
+ sub unwrap {
+       my $token = (@_ ? shift @_ : $_)->[0];
+       # simple case: 'sqstring' or "dqstring"
+       return $token if $token =~ s/^'([^']*)'$/$1/;
+       return $token if $token =~ s/^"([^"]*)"$/$1/;
+       # composite case
+       my ($s, $q, $escaped);
+       while (1) {
+               # slurp up non-special characters
+               $s .= $1 if $token =~ /\G([^\\'"]*)/gc;
+               # handle special characters
+               last unless $token =~ /\G(.)/sgc;
+               my $c = $1;
+               $q = undef, next if defined($q) && $c eq $q;
+               $q = $c, next if !defined($q) && $c =~ /^['"]$/;
+               if ($c eq '\\') {
+                       last unless $token =~ /\G(.)/sgc;
+                       $c = $1;
+                       $s .= '\\' if $c eq "\n"; # preserve line splice
+               }
+               $s .= $c;
+       }
+       return $s
+ }
+ sub check_test {
+       # no-op; subclass and override to implement lint checks
+ }
+ sub parse_cmd {
+       my $self = shift @_;
+       my @tokens = $self->SUPER::parse_cmd();
+       return @tokens unless @tokens && $tokens[0]->[0] =~ /^test_expect_(?:success|failure)$/;
+       my $n = $#tokens;
+       $n-- while $n >= 0 && $tokens[$n]->[0] =~ /^(?:[;&\n|]|&&|\|\|)$/;
+       my $herebody;
+       if ($n >= 2 && $tokens[$n-1]->[0] eq '-' && $tokens[$n]->[0] =~ /^<<-?(.+)$/) {
+               $herebody = $self->{heredocs}->{$1};
+               $n--;
+       }
+       $self->check_test($tokens[1], $tokens[2], $herebody) if $n == 2; # title body
+       $self->check_test($tokens[2], $tokens[3], $herebody) if $n > 2;  # prereq title body
+       return @tokens;
+ }
+ 1;
diff --cc t/t0001-init.sh
Simple merge
diff --cc t/t0012-help.sh
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
index 6e921bc1678503344f9e9b756c575d3bda5e712e,81de1d40eba7fd70f5fe1034c93b33231e31acf4..2e024afc8223d8743393f34f0846a0c22277deee
@@@ -107,13 -107,13 +107,13 @@@ test_expect_success setup 
  '
  
  test_expect_success 'correct usage on sub-command -h' '
 -      test_expect_code 129 git reflog expire -h >err &&
 +      git reflog expire -h >err &&
-       grep "git reflog expire" err
+       test_grep "git reflog expire" err
  '
  
  test_expect_success 'correct usage on "git reflog show -h"' '
 -      test_expect_code 129 git reflog show -h >err &&
 +      git reflog show -h >err &&
-       grep -F "git reflog [show]" err
+       test_grep -F "git reflog [show]" err
  '
  
  test_expect_success 'pass through -- to sub-command' '
Simple merge
diff --cc t/t1800-hook.sh
index 2ea9fa13c53149ddbe3620f3448569786e02b869,9aae3ff074519147471ed223d47281587e8fce07..7d4b2982b4a6f766bd1e87c19c935ea4f37648cb
@@@ -75,11 -75,11 +75,11 @@@ sentinel_detector () 
  test_expect_success 'git hook usage' '
        test_expect_code 129 git hook &&
        test_expect_code 129 git hook run &&
 -      test_expect_code 129 git hook run -h &&
 +      git hook run -h &&
        test_expect_code 129 git hook run --unknown 2>err &&
        test_expect_code 129 git hook list &&
 -      test_expect_code 129 git hook list -h &&
 +      git hook list -h &&
-       grep "unknown option" err
+       test_grep "unknown option" err
  '
  
  test_expect_success 'git hook list: unknown hook name is rejected' '
Simple merge
Simple merge
index bc07e2a6eca43502b937264c0a74b423acf70737,53328b84755511654530e09d1cb0c83b81ab6171..da27a6599a6a79481fd070405791b6d8bad33355
@@@ -27,14 -27,14 +27,14 @@@ test_expect_success 'usage on cmd and s
  '
  
  test_expect_success 'usage on main command -h emits a summary of subcommands' '
 -      test_expect_code 129 git stash -h >usage &&
 +      git stash -h >usage &&
-       grep -F "usage: git stash list" usage &&
-       grep -F "or: git stash show" usage
+       test_grep -F "usage: git stash list" usage &&
+       test_grep -F "or: git stash show" usage
  '
  
  test_expect_success 'usage for subcommands should emit subcommand usage' '
 -      test_expect_code 129 git stash push -h >usage &&
 +      git stash push -h >usage &&
-       grep -F "usage: git stash [push" usage
+       test_grep -F "usage: git stash [push" usage
  '
  
  diff_cmp () {
Simple merge
index 9dbed940db5eb80f56bbced3c9bfb6363ae5f899,b114a7adf769957bcdf401bcf00799fcbf0aabec..6bc1c7a663b3578054533adefb3de32409546e21
@@@ -13,9 -14,9 +13,9 @@@ test_expect_success EXPENSIVE 'git appl
                +++ b/file
                @@ -0,0 +1 @@
                EOF
 -              test-tool genzeros
 -      } | test_copy_bytes $sz | test_must_fail git apply 2>err &&
 +              test-tool genzeros $((1024 * 1024 * 1023))
 +      } | test_must_fail git apply 2>err &&
-       grep "patch too large" err
+       test_grep "patch too large" err
  '
  
  test_done
Simple merge
diff --cc t/t4202-log.sh
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
index 2294645902c90e1e76fcaee06b2291d2fcd93854,35b1f212a8156f205f908e4a8c1a0282756dd94e..5f8aa1ed19564e3265b12f982aa6c60f4652ace7
@@@ -51,40 -51,9 +51,40 @@@ test_expect_success 'detect empty remot
  # even targeted refspecs are handled.
  test_expect_success 'detect empty remote with targeted refspec' '
        test_must_fail git push "" HEAD:refs/heads/main 2> stderr &&
-       grep "fatal: bad repository ${SQ}${SQ}" stderr
+       test_grep "fatal: bad repository ${SQ}${SQ}" stderr
  '
  
 +test_expect_success 'suggest <remote> <branch> for a <remote>/<branch> slip' '
 +      test_must_fail git push origin/main 2>stderr &&
 +      test_grep "${SQ}origin/main${SQ} is not a valid push target" stderr &&
 +      test_grep "hint: Did you mean to use: git push origin main?" stderr &&
 +      test_must_fail git -c advice.pushRepoLooksLikeRef=false push origin/main 2>stderr &&
 +      test_grep ! "Did you mean" stderr
 +'
 +
 +test_expect_success 'suggest <remote> <branch> when the branch has slashes' '
 +      test_must_fail git push origin/feature/x 2>stderr &&
 +      test_grep "hint: Did you mean to use: git push origin feature/x?" stderr
 +'
 +
 +test_expect_success 'no suggestion when prefix is not a configured remote' '
 +      test_must_fail git push not-a-remote/main 2>stderr &&
 +      test_grep ! "Did you mean" stderr
 +'
 +
 +test_expect_success 'no suggestion for a trailing slash with no branch' '
 +      test_must_fail git push origin/ 2>stderr &&
 +      test_grep ! "Did you mean" stderr
 +'
 +
 +test_expect_success 'no suggestion when the argument is an existing path' '
 +      test_when_finished "rm -rf origin" &&
 +      git init --bare origin/main &&
 +      git push origin/main HEAD:refs/heads/pushed 2>stderr &&
 +      test_grep ! "Did you mean" stderr &&
 +      git -C origin/main rev-parse --verify refs/heads/pushed
 +'
 +
  test_expect_success 'detect ambiguous refs early' '
        git branch foo &&
        git tag foo &&
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
diff --cc t/t6500-gc.sh
Simple merge
index 0ff41381ff3c522ad7897445a3d1a2467c3f6719,ee46d66b841fba9a63f59fabc41e93994c9525ee..019fed9f5ec0eae63d73a46e17554a49870debf5
@@@ -1000,12 -964,7 +1000,12 @@@ test_expect_success 'merge-base withou
        git merge-base --all commit-5-7 commit-4-8 commit-6-6 commit-8-3 >all &&
        git merge-base commit-5-7 commit-4-8 commit-6-6 commit-8-3 >single &&
        test_line_count = 1 single &&
-       grep -F -f single all
+       test_grep -F -f single all
  '
  
 +test_expect_success 'merge-base without --all, clock skew, v1 commit-graph' '
 +      git rev-parse skew-M2 >expect &&
 +      merge_base_all_modes skew-P1 skew-P2
 +'
 +
  test_done
Simple merge
index 69a17a9d139b83dbcbb1a1c453bf33b60fa5fe26,bb727b38fc7000699fddedf250630b2ad38744be..72c7f6f73be3fa7bdc2a2e540df92efc4ad50d66
@@@ -350,7 -356,7 +356,7 @@@ test_expect_success 'git dirs of siblin
  test_expect_success 'submodule git dir nesting detection must work with parallel cloning' '
        test_must_fail git clone --recurse-submodules --jobs=2 nested clone_parallel 2>err &&
        cat err &&
-       grep -E "(already exists|is inside git dir|does not point to a valid repository)" err &&
 -      test_grep -E "(already exists|is inside git dir|not a git repository)" err &&
++      test_grep -E "(already exists|is inside git dir|does not point to a valid repository)" err &&
        {
                test_path_is_missing .git/modules/hippo/HEAD ||
                test_path_is_missing .git/modules/hippo/hooks/HEAD
Simple merge
Simple merge
Simple merge
Simple merge
Simple merge
diff --cc t/t7810-grep.sh
Simple merge
Simple merge
Simple merge