From: Junio C Hamano Date: Sun, 19 Jul 2026 17:42:17 +0000 (-0700) Subject: Merge branch 'mm/test-grep-lint' X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c9a92e239f17895e8119a0c1803544bb457c8755;p=thirdparty%2Fgit.git Merge branch 'mm/test-grep-lint' 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 --- c9a92e239f17895e8119a0c1803544bb457c8755 diff --cc t/for-each-ref-tests.sh index b95e5b6ca0,6b359d940e..b1a10dd933 --- a/t/for-each-ref-tests.sh +++ b/t/for-each-ref-tests.sh @@@ -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 diff --cc t/lib-shell-parser.pl index 0000000000,17fbf461b1..d78e18510e mode 000000,100644..100644 --- a/t/lib-shell-parser.pl +++ b/t/lib-shell-parser.pl @@@ -1,0 -1,534 +1,534 @@@ + # Copyright (c) 2021-2022 Eric Sunshine + # + # 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 whitespace, is scanned as three token in shell, as well. ++# `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 `< $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] : "") . "'\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/t1410-reflog.sh index 6e921bc167,81de1d40eb..2e024afc82 --- a/t/t1410-reflog.sh +++ b/t/t1410-reflog.sh @@@ -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' ' diff --cc t/t1800-hook.sh index 2ea9fa13c5,9aae3ff074..7d4b2982b4 --- a/t/t1800-hook.sh +++ b/t/t1800-hook.sh @@@ -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' ' diff --cc t/t3903-stash.sh index bc07e2a6ec,53328b8475..da27a6599a --- a/t/t3903-stash.sh +++ b/t/t3903-stash.sh @@@ -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 () { diff --cc t/t4141-apply-too-large.sh index 9dbed940db,b114a7adf7..6bc1c7a663 --- a/t/t4141-apply-too-large.sh +++ b/t/t4141-apply-too-large.sh @@@ -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 diff --cc t/t5529-push-errors.sh index 2294645902,35b1f212a8..5f8aa1ed19 --- a/t/t5529-push-errors.sh +++ b/t/t5529-push-errors.sh @@@ -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 for a / 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 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 && diff --cc t/t6600-test-reach.sh index 0ff41381ff,ee46d66b84..019fed9f5e --- a/t/t6600-test-reach.sh +++ b/t/t6600-test-reach.sh @@@ -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 diff --cc t/t7450-bad-git-dotfiles.sh index 69a17a9d13,bb727b38fc..72c7f6f73b --- a/t/t7450-bad-git-dotfiles.sh +++ b/t/t7450-bad-git-dotfiles.sh @@@ -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