#!/usr/bin/env perl

# gitudiff
#
#   This script extracts a unified-diff between two git SHAs
#   See
#      $ gitdiff --help
#

use Getopt::Long;
use strict;
use Cwd qw(realpath);

my $verbose = 0;

sub include_me
{
    my ($path, $includes, $excludes) = @_;

    foreach my $pat (@$excludes) {
        if ($path =~ /$pat/) {
            if ($verbose) {
                print(STDERR "exclude '$path', pattern '$pat'\n");
            }
            return 0;
        }
    }
    return 1
        if 0 == scalar(@$includes);

    foreach my $pat (@$includes) {
        if ($path =~ /$pat/) {
            if ($verbose) {
                print(STDERR "include '$path', pattern '$pat'\n");
            }
            return 1;
        }
    }
    if ($verbose > 1) {
        print(STDERR "exclude '$path': no match\n");
    }
    return 0;
}

my @exclude_patterns;
my @include_patterns;
my $suppress_unchanged;
my $prefix = '';
my $ignore_whitespace;
my $repo = '.';    # cwd
my $help;

if (!GetOptions("exclude=s"    => \@exclude_patterns,
                "include=s"    => \@include_patterns,
                'no-unchanged' => \$suppress_unchanged,
                'prefix=s'     => \$prefix,
                'b|blank'      => \$ignore_whitespace,
                'repo=s'       => \$repo,
                'verbose+'     => \$verbose,
                'help'         => \$help) ||
    $help ||
    (2 != scalar(@ARGV) &&
        3 != scalar(@ARGV))
) {
    print(STDERR<<EOF
Usage:
  $0 \\
      [(--exclude|include) regexp[,regexp]] [--repo repo_directory] \\
      [-b] [dir] base_SHA current_SHA

 --repo repo_directory:
          your git repo directory.  Default is '.'.
 --prefix:
          leading path to strip from file pathnames in the diff report
 --no-unchanged:
          In order to eliminate potential ambiguities caused by multiple
          source files with the same basename, by default, we include
          unchanged file references in the diff report.
          This option removes those entries.
   -b|--blank:
          ignore whitespace changes.
 --include regexp:
          regexp compared to the Perforce path (i.e., starting in
          //p4_repo/...).  If 'include' regexps are specified, then only
          Matching references are included in the report; if no
          'include' regexps are specified, then entries are included unless
          they have been excluded.
 --exclude regexp:
          regexp compared to the Perforce path (i.e., starting in
          //p4_repo/...).  Matching entries are removed from the report.
         'exclude' wins if both exclude and include would match.
EOF
    );
    exit(1);
}

@exclude_patterns  = split(',', join(',', @exclude_patterns));
@include_patterns  = split(',', join(',', @include_patterns));
$ignore_whitespace = '-b' if $ignore_whitespace;

if ($verbose) {
    print(STDERR join(' ', @ARGV));
    if (scalar(@exclude_patterns)) {
        print(STDERR " --exclude " . join(" --exclude ", @exclude_patterns));
    }
    if (scalar(@include_patterns)) {
        print(STDERR " --include " . join(" --include ", @include_patterns));
    }
    print(STDERR "\n");
}

# add separator to end of prefix - if prefix set and doesn't end with one
$prefix .= '/' if $prefix && substr($prefix, -1) ne '/';
if (3 == scalar(@ARGV)) {
    my $dir = shift @ARGV;
    push(@include_patterns, "$dir");
}
my $base_sha    = shift @ARGV;
my $current_sha = shift @ARGV;

$repo = Cwd::realpath($repo);

die("unexpected '/' at end of '$repo'") if substr($repo, -1) eq '/';

my @cmd = ('git', '-C', $repo, 'diff');
push(@cmd, $ignore_whitespace) if $ignore_whitespace;
push(@cmd, $base_sha, $current_sha);

open(HANDLE, "-|", @cmd) or
    die("failed to exec '" . join(' ', @cmd) . "': $!");

my $includeCurrentFile;
my %allFiles;

while (<HANDLE>) {
    chomp($_);
    s/\r//g;
    my $line = $_;

    # Capture the git-relative (short) pathnames from the raw 'diff --git'
    # header before we rewrite the line below.  The path within the repo is
    # unique, so we key %allFiles by it to dedup against the ls-tree pass.
    my ($shortA, $shortB);
    if ($line =~ m#^diff --git a/(\S+) b/(\S+)#) {
        ($shortA, $shortB) = ($1, $2);
    }

    if ($line =~ /(^diff|\+\+\+|---) /) {
        # remove the a/b leader from the diff/---/+++ lines - making the
        # pathname absolute (rooted at the repo) - then strip the requested
        # prefix so the whole report uses the same (prefix-relative) paths.
        $line =~ s# [ab]/# $repo/#g;
        $line =~ s#\Q$prefix\E##g if $prefix;
    }

    if ($line =~ /^diff --git (\S+) (\S+)/) {
        # git diff header
        my $fileA = $1;
        my $fileB = $2;
        $includeCurrentFile =
            (include_me($fileA, \@include_patterns, \@exclude_patterns) ||
             include_me($fileB, \@include_patterns, \@exclude_patterns));
        if ($includeCurrentFile) {
            # key by the short (git-relative) path so the ls-tree pass can
            # recognise this file as already-changed
            $allFiles{$shortB} = $shortA;
        }
    }

    printf("%s\n", $line)
        if $includeCurrentFile;
}
# 'close' on a pipe returns false for a child which exited non-zero as well as
#   for an I/O error, and '$!' describes only the latter - the wait status is in
#   '$?'.  The '0 != $?' block which used to follow was unreachable, because the
#   'die' above it always fired first and blamed a stale errno.
unless (close(HANDLE)) {
    my $status = $?;
    die("failed to close git diff pipe: $!\n") if 0 == $status;
    die("git diff died from signal " . ($status & 0x7F) . "\n")
        if $status & 0x7F;
    die("git diff exited with error " . ($status >> 8) . "\n");
}

exit 0 if defined($suppress_unchanged);

# now find the list of files in the current SHA - we can process that
#  to find the list of all current files which were unchanged since the
#  baseline SHA
my @lsCmd = ('git', '-C', $repo, 'ls-tree', '-r', '--name-only', $current_sha);
open(HANDLE, '-|', @lsCmd) or die("failed to exec git ls-tree: $!");

while (<HANDLE>) {
    chomp($_);
    s/\r//g;
    # $repo starts with leading '/'
    my $path = $repo . '/' . $_;
    $path =~ s#\Q$prefix\E## if $prefix;    # strip prefix
        # $_ is the git-relative path, matching the key used for changed files
        # above; skip it here so changed files are not re-emitted as unchanged.
    if (!exists($allFiles{$_}) &&
        include_me($path, \@include_patterns, \@exclude_patterns)) {
        print("diff --git $path $path\n");
        print("=== $path\n");
    }
}
# see the 'git diff' close above
unless (close(HANDLE)) {
    my $status = $?;
    die("failed to close git ls-tree pipe: $!\n") if 0 == $status;
    die("git ls-tree died from signal " . ($status & 0x7F) . "\n")
        if $status & 0x7F;
    die("git ls-tree exited with error " . ($status >> 8) . "\n");
}
exit 0;
