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';
+}