From: "Michael Montalbo via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: "D. Ben Knoble" <ben.knoble@gmail.com>,
Eric Sunshine <sunshine@sunshineco.com>,
Michael Montalbo <mmontalbo@gmail.com>,
Michael Montalbo <mmontalbo@gmail.com>
Subject: [PATCH 4/6] t: add lint-style.pl with test_grep negation rule
Date: Thu, 04 Jun 2026 07:45:56 +0000 [thread overview]
Message-ID: <c1b90101ef5c38a21fc901bd7387acf83eb96806.1780559158.git.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2135.git.1780559158.gitgitgadget@gmail.com>
From: Michael Montalbo <mmontalbo@gmail.com>
Add a mechanical lint checker for test scripts, similar in spirit to
check-non-portable-shell.pl but focused on test conventions rather
than portability.
The tool defines LintParser, a subclass of ScriptParser (from the
shared lib-shell-parser.pl module). ScriptParser's
parse_cmd() finds test_expect_success blocks and calls check_test()
for each body; LintParser overrides check_test() to run lint rules
on the parsed commands. A "# lint-ok" comment suppresses all
checks for intentional style violations.
The first rule detects '! test_grep' and replaces it with
'test_grep !'. Shell-level negation suppresses the diagnostic
output that test_grep prints on failure; the built-in negation
preserves it.
Three violations inside test bodies are converted via --fix. One
additional violation in a helper function outside test_expect_success
(t7900's test_geometric_repack_needed) is converted manually, since
the parser only processes test bodies.
Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
---
t/.gitattributes | 2 +
t/Makefile | 32 +++-
t/lint-style.pl | 200 +++++++++++++++++++++
t/lint-style/heredoc.expect | 3 +
t/lint-style/heredoc.test | 14 ++
t/lint-style/test-grep-negation-fix.expect | 4 +
t/lint-style/test-grep-negation-fix.test | 4 +
t/lint-style/test-grep-negation.expect | 3 +
t/lint-style/test-grep-negation.test | 4 +
t/t0031-lockfile-pid.sh | 2 +-
t/t5300-pack-object.sh | 2 +-
t/t5319-multi-pack-index.sh | 2 +-
t/t7900-maintenance.sh | 2 +-
13 files changed, 268 insertions(+), 6 deletions(-)
create mode 100755 t/lint-style.pl
create mode 100644 t/lint-style/heredoc.expect
create mode 100644 t/lint-style/heredoc.test
create mode 100644 t/lint-style/test-grep-negation-fix.expect
create mode 100644 t/lint-style/test-grep-negation-fix.test
create mode 100644 t/lint-style/test-grep-negation.expect
create mode 100644 t/lint-style/test-grep-negation.test
diff --git a/t/.gitattributes b/t/.gitattributes
index 7664c6e027..aea6889d03 100644
--- a/t/.gitattributes
+++ b/t/.gitattributes
@@ -1,5 +1,7 @@
t[0-9][0-9][0-9][0-9]/* -whitespace
/chainlint/*.expect eol=lf -whitespace
+/lint-style/*.expect eol=lf -whitespace
+/lint-style/*.test eol=lf -whitespace
/t0110/url-* binary
/t3206/* eol=lf
/t3900/*.txt eol=lf
diff --git a/t/Makefile b/t/Makefile
index 25f923fed9..3a5fa4ce37 100644
--- a/t/Makefile
+++ b/t/Makefile
@@ -46,6 +46,7 @@ TPERF = $(sort $(wildcard perf/p[0-9][0-9][0-9][0-9]-*.sh))
TINTEROP = $(sort $(wildcard interop/i[0-9][0-9][0-9][0-9]-*.sh))
CHAINLINTTESTS = $(sort $(patsubst chainlint/%.test,%,$(wildcard chainlint/*.test)))
CHAINLINT = '$(PERL_PATH_SQ)' chainlint.pl
+LINT_STYLE_TESTS = $(sort $(wildcard lint-style/*.test))
UNIT_TEST_SOURCES = $(wildcard unit-tests/t-*.c)
UNIT_TEST_PROGRAMS = $(patsubst unit-tests/%.c,unit-tests/bin/%$(X),$(UNIT_TEST_SOURCES))
UNIT_TEST_PROGRAMS += unit-tests/bin/unit-tests$(X)
@@ -139,7 +140,7 @@ check-meson:
test-lint: test-lint-duplicates test-lint-executable \
test-lint-filenames
ifneq ($(PERL_PATH),)
-test-lint: test-lint-shell-syntax check-shell-parser
+test-lint: test-lint-shell-syntax test-lint-style check-lint-style check-shell-parser
else
GIT_TEST_CHAIN_LINT = 0
endif
@@ -162,6 +163,32 @@ test-lint-shell-syntax:
check-shell-parser:
@'$(PERL_PATH_SQ)' check-shell-parser.pl
+
+test-lint-style:
+ @'$(PERL_PATH_SQ)' lint-style.pl $(T) $(THELPERS) $(TPERF)
+
+check-lint-style:
+ @rc=0; for t in $(LINT_STYLE_TESTS); do \
+ base=$${t%.test}; \
+ case $$base in \
+ *-fix) \
+ cp "$$t" "$$t.tmp" && \
+ '$(PERL_PATH_SQ)' lint-style.pl --fix "$$t.tmp" >/dev/null 2>&1; \
+ fix_rc=$$?; \
+ if test $$fix_rc != 0; then \
+ echo "FAIL: $$t (--fix exit code $$fix_rc)"; rc=1; \
+ elif ! diff -u "$$base.expect" "$$t.tmp"; then \
+ echo "FAIL: $$t (--fix output)"; rc=1; \
+ fi; \
+ rm -f "$$t.tmp" ;; \
+ *) \
+ if ! '$(PERL_PATH_SQ)' lint-style.pl "$$t" 2>&1 | \
+ diff -u "$$base.expect" -; then \
+ echo "FAIL: $$t"; rc=1; \
+ fi ;; \
+ esac; \
+ done; test $$rc = 0
+
test-lint-filenames:
@# We do *not* pass a glob to ls-files but use grep instead, to catch
@# non-ASCII characters (which are quoted within double-quotes)
@@ -188,7 +215,8 @@ perf:
.PHONY: pre-clean $(T) aggregate-results clean valgrind perf \
check-chainlint clean-chainlint test-chainlint \
- check-shell-parser $(UNIT_TESTS)
+ check-shell-parser \
+ check-lint-style test-lint-style $(UNIT_TESTS)
.PHONY: libgit-sys-test libgit-rs-test
libgit-sys-test:
diff --git a/t/lint-style.pl b/t/lint-style.pl
new file mode 100755
index 0000000000..9268577f9b
--- /dev/null
+++ b/t/lint-style.pl
@@ -0,0 +1,200 @@
+#!/usr/bin/perl
+
+# Check test scripts for style violations that can be detected
+# mechanically, such as using bare 'grep' where test_grep should
+# be used. Use --fix to automatically apply suggested replacements.
+#
+# Detection uses parsed tokens from the shared shell parser for
+# correct handling of heredocs, $(...), pipes, and quoting.
+# Fixes modify the original file text to preserve formatting.
+
+use strict;
+use warnings;
+use File::Basename;
+# Force LF output so check-lint-style's diff against the
+# pre-committed .expect files works on Windows.
+binmode(STDOUT, ':unix');
+binmode(STDERR, ':unix');
+
+my $fix_mode = 0;
+if (@ARGV && $ARGV[0] eq '--fix') {
+ $fix_mode = 1;
+ shift @ARGV;
+}
+
+# Load the shared shell parser (Lexer, ShellParser, ScriptParser).
+my $_lib = dirname($0) . "/lib-shell-parser.pl";
+$_lib = "./$_lib" unless $_lib =~ m{^/};
+do $_lib or die "$0: failed to load $_lib: $@$!\n";
+
+# LintParser is a subclass of ScriptParser which runs lint rules
+# on each test body. Per-file state (file name, raw lines, dirty
+# flag) is stored on the instance before calling parse().
+#
+# Subroutines defined below (parse_commands, check_test_grep_negation,
+# etc.) are in package main and called with the main:: prefix.
+# File-scoped lexicals ($fix_mode, $has_fixable, etc.) are visible
+# across packages since 'package' does not introduce a new scope.
+package LintParser;
+our @ISA = ('ScriptParser');
+
+package main;
+
+my $exit_code = 0;
+my $has_fixable = 0;
+
+sub err {
+ my ($file, $lineno, $line, $msg, %opts) = @_;
+ $line =~ s/^\s+//;
+ $line =~ s/\s+$//;
+ $line =~ s/\s+/ /g;
+ my $prefix = ($fix_mode && $opts{fixable}) ? 'fixed' : 'error';
+ print "$file:$lineno: $prefix: $msg: $line\n";
+ $exit_code = 1 unless $fix_mode && $opts{fixable};
+}
+
+# Report a lint violation found by a rule. In --fix mode, apply
+# the regex substitution on the raw line and report success.
+# Otherwise just report. Returns 1 if the line was modified.
+sub report_violation {
+ my ($file, $cmd, $line_ref, $match, $fix, $from) = @_;
+ my $lineno = $cmd->{lineno};
+ my $display = join(' ', @{$cmd->{tokens}});
+ $has_fixable++; # count for the "--fix" hint
+ if ($fix_mode) {
+ if ($$line_ref =~ s/$match/$fix/) {
+ err $file, $lineno, $display,
+ "replace '$from' with '$fix'",
+ fixable => 1;
+ return 1;
+ }
+ err $file, $lineno, $display,
+ "replace '$from' with '$fix' (could not auto-fix)";
+ } else {
+ err $file, $lineno, $display,
+ "replace '$from' with '$fix'";
+ }
+ return 0;
+}
+
+# Split a token stream into commands at &&, ||, ;;, and \n.
+sub parse_commands {
+ my ($content) = @_;
+ my $parser = ShellParser->new(\$content);
+ my @all_tokens = $parser->parse();
+
+ my @commands;
+ my @current;
+ my $lineno = 1;
+
+ for (my $ti = 0; $ti < @all_tokens; $ti++) {
+ my $text = $all_tokens[$ti]->[0];
+ if ($text =~ /^(?:&&|\|\||;;|\n)$/) {
+ if (@current) {
+ push @commands, {
+ tokens => [@current],
+ lineno => $lineno,
+ };
+ @current = ();
+ }
+ } else {
+ $lineno = $all_tokens[$ti]->[3]
+ if !@current && defined $all_tokens[$ti]->[3];
+ push @current, $text;
+ }
+ }
+ if (@current) {
+ push @commands, {
+ tokens => [@current],
+ lineno => $lineno,
+ };
+ }
+ return @commands;
+}
+
+# --- Rule: '! test_grep' should be 'test_grep !' ---
+# Shell-level negation suppresses test_grep's diagnostic output
+# on failure. Built-in negation preserves it.
+sub check_test_grep_negation {
+ my ($cmd, $file, $line_ref) = @_;
+ my @tokens = @{$cmd->{tokens}};
+ return unless @tokens >= 2 && $tokens[0] eq '!' && $tokens[1] eq 'test_grep';
+
+ return report_violation($file, $cmd, $line_ref,
+ qr/!\s*test_grep/, 'test_grep !', '! test_grep');
+}
+
+# Map parsed commands back to raw file lines for --fix.
+# Detection uses parsed tokens (correct handling of quoting,
+# heredocs, pipes) but fixes must modify the original text
+# to preserve formatting.
+package LintParser;
+
+sub check_test {
+ # Called by ScriptParser::parse_cmd for each test_expect_success
+ # or test_expect_failure block.
+ my $self = shift @_;
+ my $title = ScriptParser::unwrap(shift @_);
+
+ # Two test body formats:
+ # Quoted: test_expect_success 'title' '..body..'
+ # Heredoc: test_expect_success 'title' - <<\EOF
+ # ..body..
+ # EOF
+ # For quoted, the body token is the quoted string.
+ # For heredoc, the body token is '-' and the actual
+ # code arrives as the next argument from the Lexer.
+ my $body_token = shift @_;
+ my $lineno_base = $body_token->[3] || 1;
+ my $body = ScriptParser::unwrap($body_token);
+
+ if ($body eq '-') {
+ my $herebody = shift @_;
+ if ($herebody) {
+ $body = $herebody->{content};
+ $lineno_base = $herebody->{start_line} || 1;
+ }
+ }
+ return unless $body;
+
+ # Map each command back to its file line number.
+ # $lineno_base is where the body starts in the file;
+ # $cmd->{lineno} is relative to the body (starting at 1).
+ my $raw_lines = $self->{raw_lines};
+ for my $cmd (main::parse_commands($body)) {
+ my $ln = ($cmd->{lineno} || 0) + $lineno_base - 1;
+ $cmd->{lineno} = $ln;
+ next unless $ln >= 1 && $ln <= @$raw_lines;
+ next if $raw_lines->[$ln - 1] =~ /#.*lint-ok/;
+
+ if (main::check_test_grep_negation($cmd, $self->{file}, \$raw_lines->[$ln - 1])) {
+ $self->{dirty} = 1;
+ }
+ }
+}
+
+package main;
+
+for my $file (@ARGV) {
+ # :unix:crlf strips \r on Windows (same as chainlint.pl)
+ open(my $fh, '<:unix:crlf', $file) or die "$0: $file: $!\n";
+ my @raw_lines = <$fh>;
+ close $fh;
+
+ my $parser = LintParser->new(\join('', @raw_lines));
+ $parser->{file} = $file;
+ $parser->{raw_lines} = \@raw_lines;
+ $parser->{dirty} = 0;
+ $parser->parse();
+
+ if ($fix_mode && $parser->{dirty}) {
+ open(my $out, '>', $file) or die "$0: $file: $!\n";
+ print $out @{$parser->{raw_lines}};
+ close $out;
+ }
+}
+
+if ($has_fixable && !$fix_mode) {
+ print "hint: run with --fix to apply the suggested replacements.\n";
+}
+exit $exit_code;
diff --git a/t/lint-style/heredoc.expect b/t/lint-style/heredoc.expect
new file mode 100644
index 0000000000..7ff6d4a52d
--- /dev/null
+++ b/t/lint-style/heredoc.expect
@@ -0,0 +1,3 @@
+lint-style/heredoc.test:8: error: replace '! test_grep' with 'test_grep !': ! test_grep "after-heredoc-is-caught" actual
+lint-style/heredoc.test:13: error: replace '! test_grep' with 'test_grep !': ! test_grep "not-inside-sed-heredoc" actual
+hint: run with --fix to apply the suggested replacements.
diff --git a/t/lint-style/heredoc.test b/t/lint-style/heredoc.test
new file mode 100644
index 0000000000..4c05831cfb
--- /dev/null
+++ b/t/lint-style/heredoc.test
@@ -0,0 +1,14 @@
+test_expect_success 'greps inside heredocs are skipped' '
+ cat <<-EOF &&
+ grep "inside-strip-tabs" file
+ EOF
+ cat <<-\EOF &&
+ grep "inside-no-expand" file
+ EOF
+ ! test_grep "after-heredoc-is-caught" actual
+'
+
+test_expect_success 'sed with << does not start a heredoc' '
+ sed "s/<< foo/bar/" file &&
+ ! test_grep "not-inside-sed-heredoc" actual
+'
diff --git a/t/lint-style/test-grep-negation-fix.expect b/t/lint-style/test-grep-negation-fix.expect
new file mode 100644
index 0000000000..28ecde1073
--- /dev/null
+++ b/t/lint-style/test-grep-negation-fix.expect
@@ -0,0 +1,4 @@
+test_expect_success 'negated test_grep' '
+ test_grep ! "pattern" actual &&
+ test_grep ! -i "insensitive" actual
+'
diff --git a/t/lint-style/test-grep-negation-fix.test b/t/lint-style/test-grep-negation-fix.test
new file mode 100644
index 0000000000..571c150031
--- /dev/null
+++ b/t/lint-style/test-grep-negation-fix.test
@@ -0,0 +1,4 @@
+test_expect_success 'negated test_grep' '
+ ! test_grep "pattern" actual &&
+ ! test_grep -i "insensitive" actual
+'
diff --git a/t/lint-style/test-grep-negation.expect b/t/lint-style/test-grep-negation.expect
new file mode 100644
index 0000000000..1fa9e124aa
--- /dev/null
+++ b/t/lint-style/test-grep-negation.expect
@@ -0,0 +1,3 @@
+lint-style/test-grep-negation.test:2: error: replace '! test_grep' with 'test_grep !': ! test_grep "pattern" actual
+lint-style/test-grep-negation.test:3: error: replace '! test_grep' with 'test_grep !': ! test_grep -i "insensitive" actual
+hint: run with --fix to apply the suggested replacements.
diff --git a/t/lint-style/test-grep-negation.test b/t/lint-style/test-grep-negation.test
new file mode 100644
index 0000000000..571c150031
--- /dev/null
+++ b/t/lint-style/test-grep-negation.test
@@ -0,0 +1,4 @@
+test_expect_success 'negated test_grep' '
+ ! test_grep "pattern" actual &&
+ ! test_grep -i "insensitive" actual
+'
diff --git a/t/t0031-lockfile-pid.sh b/t/t0031-lockfile-pid.sh
index 8ef87addf5..e9e2f04049 100755
--- a/t/t0031-lockfile-pid.sh
+++ b/t/t0031-lockfile-pid.sh
@@ -29,7 +29,7 @@ test_expect_success 'PID info not shown by default' '
test_must_fail git add . 2>err &&
# Should not crash, just show normal error without PID
test_grep "Unable to create" err &&
- ! test_grep "is held by process" err
+ test_grep ! "is held by process" err
)
'
diff --git a/t/t5300-pack-object.sh b/t/t5300-pack-object.sh
index 73445782e7..3179b4963e 100755
--- a/t/t5300-pack-object.sh
+++ b/t/t5300-pack-object.sh
@@ -720,7 +720,7 @@ test_expect_success '--name-hash-version=2 and --write-bitmap-index are incompat
# --stdout option silently removes --write-bitmap-index
git pack-objects --stdout --all --name-hash-version=2 --write-bitmap-index >out 2>err &&
- ! test_grep "currently, --write-bitmap-index requires --name-hash-version=1" err
+ test_grep ! "currently, --write-bitmap-index requires --name-hash-version=1" err
'
test_expect_success '--path-walk pack everything' '
diff --git a/t/t5319-multi-pack-index.sh b/t/t5319-multi-pack-index.sh
index fa0d4046f7..9154d9795f 100755
--- a/t/t5319-multi-pack-index.sh
+++ b/t/t5319-multi-pack-index.sh
@@ -1175,7 +1175,7 @@ test_expect_success 'load reverse index when missing .idx, .pack' '
test_expect_success 'usage shown without sub-command' '
test_expect_code 129 git multi-pack-index 2>err &&
- ! test_grep "unrecognized subcommand" err
+ test_grep ! "unrecognized subcommand" err
'
test_expect_success 'complains when run outside of a repository' '
diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh
index d7f82e1bec..9db4a76f67 100755
--- a/t/t7900-maintenance.sh
+++ b/t/t7900-maintenance.sh
@@ -664,7 +664,7 @@ test_geometric_repack_needed () {
true)
test_grep "\[\"git\",\"repack\"," trace2.txt;;
false)
- ! test_grep "\[\"git\",\"repack\"," trace2.txt;;
+ test_grep ! "\[\"git\",\"repack\"," trace2.txt;;
*)
BUG "invalid parameter: $NEEDED";;
esac
--
gitgitgadget
next prev parent reply other threads:[~2026-06-04 7:46 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-04 7:45 [PATCH 0/6] t: add lint-style.pl and convert grep to test_grep Michael Montalbo via GitGitGadget
2026-06-04 7:45 ` [PATCH 1/6] t/README: document test_grep helper Michael Montalbo via GitGitGadget
2026-06-04 7:45 ` [PATCH 2/6] t: extract chainlint's parser into shared module Michael Montalbo via GitGitGadget
2026-06-04 7:45 ` [PATCH 3/6] t: fix Lexer line count for $() inside double-quoted strings Michael Montalbo via GitGitGadget
2026-06-04 7:45 ` Michael Montalbo via GitGitGadget [this message]
2026-06-04 18:34 ` [PATCH 4/6] t: add lint-style.pl with test_grep negation rule D. Ben Knoble
2026-06-04 19:36 ` Michael Montalbo
2026-06-04 7:45 ` [PATCH 5/6] t: fix grep assertions missing file arguments Michael Montalbo via GitGitGadget
2026-06-04 7:45 ` [PATCH 6/6] t: lint and convert grep assertions to test_grep Michael Montalbo via GitGitGadget
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=c1b90101ef5c38a21fc901bd7387acf83eb96806.1780559158.git.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=ben.knoble@gmail.com \
--cc=git@vger.kernel.org \
--cc=mmontalbo@gmail.com \
--cc=sunshine@sunshineco.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox