Add some trivial tests for git-if-needed.
Change-Id: I5f8fde2aebb3ab051aa5afbc7d5a093f454cfbbb
diff --git a/tools/git-if-needed/tests/data/add-readme.patch b/tools/git-if-needed/tests/data/add-readme.patch
new file mode 100644
index 0000000..a4ed37c
--- /dev/null
+++ b/tools/git-if-needed/tests/data/add-readme.patch
@@ -0,0 +1,22 @@
+From c07b8768c7d2718eaea27fcec8d804c0adbdd577 Mon Sep 17 00:00:00 2001
+From: Peter Penchev <openstack-dev@storpool.com>
+Date: Fri, 29 Nov 2019 23:23:48 +0200
+Subject: [PATCH 1/2] Add the README.txt file.
+
+Change-Id: If8c2dee7d1e70d88e0bd0f662373237e105be13d
+---
+ README.txt | 2 ++
+ 1 file changed, 2 insertions(+)
+ create mode 100644 README.txt
+
+diff --git a/README.txt b/README.txt
+new file mode 100644
+index 0000000..e16c97f
+--- /dev/null
++++ b/README.txt
+@@ -0,0 +1,2 @@
++This is a sample Git repository for testing the operation of
++the git-if-needed tool.
+--
+2.24.0
+
diff --git a/tools/git-if-needed/tests/data/another-file.patch b/tools/git-if-needed/tests/data/another-file.patch
new file mode 100644
index 0000000..6cd5bce
--- /dev/null
+++ b/tools/git-if-needed/tests/data/another-file.patch
@@ -0,0 +1,21 @@
+From 2c8b59ff960b22a39f143ce1667963c259bb2bcc Mon Sep 17 00:00:00 2001
+From: Peter Penchev <openstack-dev@storpool.com>
+Date: Tue, 3 Dec 2019 12:31:35 +0200
+Subject: [PATCH] Add another file.
+
+Change-Id: I4953764f89bbac8b8d939657356c68c81255f1b4
+---
+ TODO.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100644 TODO.txt
+
+diff --git a/TODO.txt b/TODO.txt
+new file mode 100644
+index 0000000..bd1ee7c
+--- /dev/null
++++ b/TODO.txt
+@@ -0,0 +1 @@
++Do some things.
+--
+2.24.0
+
diff --git a/tools/git-if-needed/tests/data/conflict-in-readme.patch b/tools/git-if-needed/tests/data/conflict-in-readme.patch
new file mode 100644
index 0000000..335e905
--- /dev/null
+++ b/tools/git-if-needed/tests/data/conflict-in-readme.patch
@@ -0,0 +1,21 @@
+From 7acc0c08b31c46a358e496cf69fb2750bfdbe823 Mon Sep 17 00:00:00 2001
+From: Peter Penchev <openstack-dev@storpool.com>
+Date: Fri, 29 Nov 2019 23:25:13 +0200
+Subject: [PATCH] Make a conflicting change to the README file.
+
+Change-Id: I74a6d83a52acb5c72d088c0c3d1d74f10c5f592f
+---
+ README.txt | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/README.txt b/README.txt
+index e16c97f..0ed1e0e 100644
+--- a/README.txt
++++ b/README.txt
+@@ -1,2 +1,2 @@
+ This is a sample Git repository for testing the operation of
+-the git-if-needed tool.
++the git-if-needed tool. There is nothing more to it.
+--
+2.24.0
+
diff --git a/tools/git-if-needed/tests/data/modify-readme.patch b/tools/git-if-needed/tests/data/modify-readme.patch
new file mode 100644
index 0000000..101253e
--- /dev/null
+++ b/tools/git-if-needed/tests/data/modify-readme.patch
@@ -0,0 +1,22 @@
+From 1616666e77742002b1d02b8bdd3092048cfa3f3f Mon Sep 17 00:00:00 2001
+From: Peter Penchev <openstack-dev@storpool.com>
+Date: Fri, 29 Nov 2019 23:24:27 +0200
+Subject: [PATCH 2/2] Make a change to the README file.
+
+Change-Id: I7b1587d3f10e717fc4d28b51aac38ae9247fc843
+---
+ README.txt | 2 ++
+ 1 file changed, 2 insertions(+)
+
+diff --git a/README.txt b/README.txt
+index e16c97f..38358ac 100644
+--- a/README.txt
++++ b/README.txt
+@@ -1,2 +1,4 @@
+ This is a sample Git repository for testing the operation of
+ the git-if-needed tool.
++
++No need to wonder about the meaning behind it all, is there?
+--
+2.24.0
+
diff --git a/tools/git-if-needed/tests/gifn-test.pl b/tools/git-if-needed/tests/gifn-test.pl
new file mode 100755
index 0000000..5de957d
--- /dev/null
+++ b/tools/git-if-needed/tests/gifn-test.pl
@@ -0,0 +1,390 @@
+#!/usr/bin/perl
+#
+# Copyright (c) 2019 Peter Pentchev
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+
+use v5.10;
+use strict;
+use warnings;
+
+use File::Temp;
+use Getopt::Std;
+use List::Util qw(all);
+use Path::Tiny;
+
+use constant VERSION_STRING => '0.1.0';
+
+use constant RE_CHANGE_ID => qr{
+ ^ \s* Change-Id: \s* (?<id> I[0-9a-f]+ ) \s* $
+}xi;
+
+use constant PATCHFILES => {
+ initial => [qw(add modify)],
+ conflict => [qw(conflict)],
+ more => [qw(another)],
+
+ all => {
+ add => 'add-readme.patch',
+ modify => 'modify-readme.patch',
+ conflict => 'conflict-in-readme.patch',
+ another => 'another-file.patch',
+ },
+};
+
+my $debug = 0;
+
+sub usage($)
+{
+ my ($err) = @_;
+ my $s = <<EOUSAGE
+Usage: gifn-test [-Nv] cmd [arg...]
+ gifn-test -V | -h | --version | --help
+ gifn-test --features
+
+ -h display program usage information and exit
+ -N no-operation mode
+ -V display program version information and exit
+ -v verbose operation; display diagnostic output
+EOUSAGE
+ ;
+
+ if ($err) {
+ die $s;
+ } else {
+ print "$s";
+ }
+}
+
+sub version()
+{
+ say 'gifn-test '.VERSION_STRING;
+}
+
+sub features()
+{
+ say 'Features: gifn_test='.VERSION_STRING;
+}
+
+sub debug($)
+{
+ say STDERR "RDBG $_[0]" if $debug;
+}
+
+sub check_wait_result($ $ $)
+{
+ my ($stat, $pid, $name) = @_;
+ my $sig = $stat & 127;
+ if ($sig != 0) {
+ die "Program '$name' (pid $pid) was killed by signal $sig\n";
+ } else {
+ my $code = $stat >> 8;
+ if ($code != 0) {
+ die "Program '$name' (pid $pid) exited with non-zero status $code\n";
+ }
+ }
+}
+
+sub run_command_unchomped(@)
+{
+ my (@cmd) = @_;
+ my $name = $cmd[0];
+
+ my $pid = open my $f, '-|';
+ if (!defined $pid) {
+ die "Could not fork for $name: $!\n";
+ } elsif ($pid == 0) {
+ debug "About to run '@cmd'";
+ exec { $name } @cmd;
+ die "Could not execute '$name': $!\n";
+ }
+ my @res = <$f>;
+ close $f;
+ check_wait_result $?, $pid, $name;
+ return @res;
+}
+
+sub run_command(@)
+{
+ my (@cmd) = @_;
+ my @lines = run_command_unchomped @cmd;
+ chomp for @lines;
+ return @lines;
+}
+
+sub run_failing_command(@)
+{
+ my (@cmd) = @_;
+
+ my @lines = eval {
+ run_command @cmd;
+ };
+ my $err = $@;
+ if (!defined $err) {
+ die "The '@cmd' command did not fail and output ".
+ scalar(@lines)." lines of text\n";
+ }
+ return $err;
+}
+
+sub help_or_version($)
+{
+ my ($opts) = @_;
+ my $has_dash = defined $opts->{'-'};
+ my $dash_help = $has_dash && $opts->{'-'} eq 'help';
+ my $dash_version = $has_dash && $opts->{'-'} eq 'version';
+ my $dash_features = $has_dash && $opts->{'-'} eq 'features';
+
+ if ($has_dash && !$dash_help && !$dash_version && !$dash_features) {
+ warn "Invalid long option '".$opts->{'-'}."' specified\n";
+ usage 1;
+ }
+ version if $opts->{V} || $dash_version;
+ usage 0 if $opts->{h} || $dash_help;
+ features if $dash_features;
+ exit 0 if $opts->{V} || $opts->{h} || $has_dash;
+}
+
+sub detect_utf8_locale()
+{
+ my @lines = run_command 'locale', '-a';
+ my %avail = map { $_ => 1 } @lines;
+ for my $pref (qw(POSIX C en_US en_CA en_GB en_AU en)) {
+ for my $ext (qw(UTF-8 utf8)) {
+ my $value = "$pref.$ext";
+ return $value if $avail{$value};
+ }
+ }
+ die "Could not find a suitable UTF-8 output locale\n";
+}
+
+sub git_status_ok($)
+{
+ my ($cfg) = @_;
+
+ my @lines = run_command @{$cfg->{git}}, 'status', '--short';
+ die "git status --short returned @lines\n" if @lines;
+}
+
+sub run_gifn_am($ $)
+{
+ my ($cfg, $short) = @_;
+
+ my @lines = run_command @{$cfg->{gifn}}, 'am',
+ $cfg->{patches}->{$short}->{patch};
+ git_status_ok $cfg;
+ return @lines;
+}
+
+sub run_failing_gifn_am($ $)
+{
+ my ($cfg, $short) = @_;
+
+ return run_failing_command @{$cfg->{gifn}}, 'am',
+ $cfg->{patches}->{$short}->{patch};
+}
+
+sub get_current_changes($)
+{
+ my ($cfg) = @_;
+
+ return map {
+ $_ =~ RE_CHANGE_ID ? ($+{id}) : ()
+ } run_command @{$cfg->{git}}, 'log', '--reverse';
+}
+
+sub equal_lists($ $)
+{
+ my ($expected, $got) = @_;
+
+ return @{$expected} == @{$got} && all { $expected->[$_] eq $got->[$_] } 0..$#{$expected};
+}
+
+sub setup_repo($)
+{
+ my ($cfg) = @_;
+
+ chdir $cfg->{repo} or die "Could not change into $cfg->{repo}: $!\n";
+ run_command @{$cfg->{git}}, 'init';
+ git_status_ok $cfg;
+
+ while (my ($short, $fname) = each %{PATCHFILES->{all}}) {
+ my $patch = $cfg->{data}->child($fname);
+ my @lines = $patch->lines_utf8({ chomp => 1 });
+ my @id = map { $_ =~ RE_CHANGE_ID ? ($+{id}): () } @lines;
+ die "No Change-Id line in $fname\n" unless @id;
+ die "Duplicate Change-Id line in $fname\n" if @id > 1;
+ $cfg->{patches}->{$short} = {
+ short => $short,
+ fname => $fname,
+ patch => $patch,
+ id => $id[0],
+ };
+ }
+
+ run_gifn_am $cfg, $_ for @{PATCHFILES->{initial}};
+
+ my @lines = get_current_changes $cfg;
+ my @expected = map {
+ $cfg->{patches}->{$_}->{id}
+ } @{PATCHFILES->{initial}};
+ die "Could not apply the initial patches: ".
+ "got [@lines], expected [@expected]\n" unless
+ equal_lists \@expected, \@lines;
+}
+
+sub test_bad_cmdline($)
+{
+ my ($cfg) = @_;
+
+ say "\ntest-bad-cmdline\n";
+ my @before = get_current_changes $cfg;
+
+ run_failing_command @{$cfg->{gifn}}, '-X', '-Y', '-Z';
+ git_status_ok $cfg;
+
+ run_failing_command @{$cfg->{gifn}}, 'am';
+ git_status_ok $cfg;
+ run_failing_command @{$cfg->{gifn}}, 'am', 'a', 'b';
+ git_status_ok $cfg;
+ run_failing_command @{$cfg->{gifn}}, 'am', 'a', 'b', 'c';
+ git_status_ok $cfg;
+
+ run_failing_command @{$cfg->{gifn}}, 'am', '/nonexistent';
+ git_status_ok $cfg;
+
+ my @after = get_current_changes $cfg;
+ die "The bad command-line invocations caused a changes change: ".
+ "before: [@before], after: [@after]\n" unless
+ equal_lists \@before, \@after;
+}
+
+sub test_already_applied($ @)
+{
+ my ($cfg, @patches) = @_;
+
+ say "\ntest-already-applied @patches\n";
+ for my $short (@patches) {
+ my @before = get_current_changes $cfg;
+ my $id = $cfg->{patches}->{$short}->{id};
+ debug "Should not try to apply $id again over @before";
+
+ my @lines = run_gifn_am $cfg, $short;
+ my $seek = qr{^ [#] .* \Q$id\E .* already \s+ present }xi;
+ my @found = grep { $_ =~ $seek } @lines;
+ die join '', map "$_\n", (
+ "Tried to apply change $id again:",
+ @lines,
+ ) unless @found;
+
+ my @after = get_current_changes $cfg;
+ die "Not even applying $id caused a changes change: ".
+ "before: [@before], after: [@after]\n" unless
+ equal_lists \@before, \@after;
+ }
+}
+
+sub test_fail_to_apply($ @)
+{
+ my ($cfg, @patches) = @_;
+
+ say "\ntest-fail-to-apply @patches\n";
+ for my $short (@patches) {
+ my @before = get_current_changes $cfg;
+ my $id = $cfg->{patches}->{$short}->{id};
+ debug "Should not be able to apply $id right now over @before";
+
+ run_failing_gifn_am $cfg, $short;
+ debug "Should be able to recover after a failed 'git am'";
+ run_command @{$cfg->{git}}, 'am', '--abort';
+ git_status_ok $cfg;
+
+ my @after = get_current_changes $cfg;
+ die "Failing to apply $id caused a changes change: ".
+ "before: [@before], after: [@after]\n" unless
+ equal_lists \@before, \@after;
+ }
+}
+
+sub test_apply($ @)
+{
+ my ($cfg, @patches) = @_;
+
+ say "\ntest-apply @patches\n";
+ for my $short (@patches) {
+ my @before = get_current_changes $cfg;
+ my $id = $cfg->{patches}->{$short}->{id};
+ debug "Should be able to apply $id over @before";
+
+ run_gifn_am $cfg, $short;
+
+ my @after = get_current_changes $cfg;
+ my @expected = (@before, $id);
+ die "Did not get the expected changes after applying $id: ".
+ "expected [@expected], got [@after]\n" unless
+ equal_lists \@expected, \@after;
+ }
+}
+
+MAIN:
+{
+ my %opts;
+
+ getopts('hNVv-:', \%opts) or usage 1;
+ help_or_version \%opts;
+ $debug = $opts{v};
+
+ usage 1 unless @ARGV;
+
+ my $cwd = path('.')->absolute;
+ my $locale = detect_utf8_locale;
+ my $repodir = File::Temp->newdir(
+ TEMPLATE => 'gifn-test.XXXXXX',
+ TMPDIR => 1);
+
+ my $cfg = {
+ cwd => $cwd,
+ data => $cwd->child('tests')->child('data'),
+ gifn => [@ARGV],
+ git => ['env', "LC_MESSAGES=$locale", 'git', '--no-pager'],
+ repo => path($repodir),
+ };
+
+ $cfg->{gifn}[0] = path($cfg->{gifn}[0])->absolute;
+
+ eval {
+ setup_repo $cfg;
+
+ test_bad_cmdline $cfg;
+ test_already_applied $cfg, @{PATCHFILES->{initial}};
+ test_fail_to_apply $cfg, @{PATCHFILES->{conflict}};
+ test_apply $cfg, @{PATCHFILES->{more}};
+ test_already_applied $cfg, (@{PATCHFILES->{initial}}, @{PATCHFILES->{more}});
+ test_fail_to_apply $cfg, @{PATCHFILES->{conflict}};
+ };
+ my $err = $@;
+ chdir $cwd;
+ die $err if $err;
+
+ say 'OK';
+}