diff options
Diffstat (limited to 'git-difftool.perl')
-rwxr-xr-x | git-difftool.perl | 515 |
1 files changed, 460 insertions, 55 deletions
diff --git a/git-difftool.perl b/git-difftool.perl index ba5e60a45e..488d14b153 100755 --- a/git-difftool.perl +++ b/git-difftool.perl @@ -1,92 +1,497 @@ -#!/usr/bin/env perl -# Copyright (c) 2009 David Aguilar +#!/usr/bin/perl +# Copyright (c) 2009, 2010 David Aguilar +# Copyright (c) 2012 Tim Henigan # # This is a wrapper around the GIT_EXTERNAL_DIFF-compatible # git-difftool--helper script. # # This script exports GIT_EXTERNAL_DIFF and GIT_PAGER for use by git. -# GIT_DIFFTOOL_NO_PROMPT, GIT_DIFFTOOL_PROMPT, and GIT_DIFF_TOOL -# are exported for use by git-difftool--helper. +# The GIT_DIFF* variables are exported for use by git-difftool--helper. # # Any arguments that are unknown to this script are forwarded to 'git diff'. +use 5.008; use strict; use warnings; -use Cwd qw(abs_path); +use Error qw(:try); use File::Basename qw(dirname); - -my $DIR = abs_path(dirname($0)); - +use File::Copy; +use File::Find; +use File::stat; +use File::Path qw(mkpath rmtree); +use File::Temp qw(tempdir); +use Getopt::Long qw(:config pass_through); +use Git; sub usage { + my $exitcode = shift; print << 'USAGE'; -usage: git difftool [--tool=<tool>] [-y|--no-prompt] ["git diff" options] +usage: git difftool [-t|--tool=<tool>] [--tool-help] + [-x|--extcmd=<cmd>] + [-g|--gui] [--no-gui] + [--prompt] [-y|--no-prompt] + [-d|--dir-diff] + ['git diff' options] USAGE - exit 1; + exit($exitcode); } -sub setup_environment +sub find_worktree { - $ENV{PATH} = "$DIR:$ENV{PATH}"; - $ENV{GIT_PAGER} = ''; - $ENV{GIT_EXTERNAL_DIFF} = 'git-difftool--helper'; + # Git->repository->wc_path() does not honor changes to the working + # tree location made by $ENV{GIT_WORK_TREE} or the 'core.worktree' + # config variable. + return Git::command_oneline('rev-parse', '--show-toplevel'); +} + +sub print_tool_help +{ + # See the comment at the bottom of file_diff() for the reason behind + # using system() followed by exit() instead of exec(). + my $rc = system(qw(git mergetool --tool-help=diff)); + exit($rc | ($rc >> 8)); } -sub exe +sub exit_cleanup { - my $exe = shift; - if ($^O eq 'MSWin32' || $^O eq 'msys') { - return "$exe.exe"; + my ($tmpdir, $status) = @_; + my $errno = $!; + rmtree($tmpdir); + if ($status and $errno) { + my ($package, $file, $line) = caller(); + warn "$file line $line: $errno\n"; } - return $exe; + exit($status | ($status >> 8)); } -sub generate_command +sub use_wt_file { - my @command = (exe('git'), 'diff'); - my $skip_next = 0; - my $idx = -1; - for my $arg (@ARGV) { - $idx++; - if ($skip_next) { - $skip_next = 0; - next; + my ($repo, $workdir, $file, $sha1) = @_; + my $null_sha1 = '0' x 40; + + if (-l "$workdir/$file" || ! -e _) { + return (0, $null_sha1); + } + + my $wt_sha1 = $repo->command_oneline('hash-object', "$workdir/$file"); + my $use = ($sha1 eq $null_sha1) || ($sha1 eq $wt_sha1); + return ($use, $wt_sha1); +} + +sub changed_files +{ + my ($repo_path, $index, $worktree) = @_; + $ENV{GIT_INDEX_FILE} = $index; + $ENV{GIT_WORK_TREE} = $worktree; + my $must_unset_git_dir = 0; + if (not defined($ENV{GIT_DIR})) { + $must_unset_git_dir = 1; + $ENV{GIT_DIR} = $repo_path; + } + + my @refreshargs = qw/update-index --really-refresh -q --unmerged/; + my @gitargs = qw/diff-files --name-only -z/; + try { + Git::command_oneline(@refreshargs); + } catch Git::Error::Command with {}; + + my $line = Git::command_oneline(@gitargs); + my @files; + if (defined $line) { + @files = split('\0', $line); + } else { + @files = (); + } + + delete($ENV{GIT_INDEX_FILE}); + delete($ENV{GIT_WORK_TREE}); + delete($ENV{GIT_DIR}) if ($must_unset_git_dir); + + return map { $_ => 1 } @files; +} + +sub setup_dir_diff +{ + my ($repo, $workdir, $symlinks) = @_; + + # Run the diff; exit immediately if no diff found + # 'Repository' and 'WorkingCopy' must be explicitly set to insure that + # if $GIT_DIR and $GIT_WORK_TREE are set in ENV, they are actually used + # by Git->repository->command*. + my $repo_path = $repo->repo_path(); + my %repo_args = (Repository => $repo_path, WorkingCopy => $workdir); + my $diffrepo = Git->repository(%repo_args); + + my @gitargs = ('diff', '--raw', '--no-abbrev', '-z', @ARGV); + my $diffrtn = $diffrepo->command_oneline(@gitargs); + exit(0) unless defined($diffrtn); + + # Build index info for left and right sides of the diff + my $submodule_mode = '160000'; + my $symlink_mode = '120000'; + my $null_mode = '0' x 6; + my $null_sha1 = '0' x 40; + my $lindex = ''; + my $rindex = ''; + my $wtindex = ''; + my %submodule; + my %symlink; + my @working_tree = (); + my @rawdiff = split('\0', $diffrtn); + + my $i = 0; + while ($i < $#rawdiff) { + if ($rawdiff[$i] =~ /^::/) { + warn << 'EOF'; +Combined diff formats ('-c' and '--cc') are not supported in +directory diff mode ('-d' and '--dir-diff'). +EOF + exit(1); } - if ($arg eq '-t' || $arg eq '--tool') { - usage() if $#ARGV <= $idx; - $ENV{GIT_DIFF_TOOL} = $ARGV[$idx + 1]; - $skip_next = 1; - next; + + my ($lmode, $rmode, $lsha1, $rsha1, $status) = + split(' ', substr($rawdiff[$i], 1)); + my $src_path = $rawdiff[$i + 1]; + my $dst_path; + + if ($status =~ /^[CR]/) { + $dst_path = $rawdiff[$i + 2]; + $i += 3; + } else { + $dst_path = $src_path; + $i += 2; } - if ($arg =~ /^--tool=/) { - $ENV{GIT_DIFF_TOOL} = substr($arg, 7); + + if ($lmode eq $submodule_mode or $rmode eq $submodule_mode) { + $submodule{$src_path}{left} = $lsha1; + if ($lsha1 ne $rsha1) { + $submodule{$dst_path}{right} = $rsha1; + } else { + $submodule{$dst_path}{right} = "$rsha1-dirty"; + } next; } - if ($arg eq '-y' || $arg eq '--no-prompt') { - $ENV{GIT_DIFFTOOL_NO_PROMPT} = 'true'; - delete $ENV{GIT_DIFFTOOL_PROMPT}; - next; + + if ($lmode eq $symlink_mode) { + $symlink{$src_path}{left} = + $diffrepo->command_oneline('show', "$lsha1"); } - if ($arg eq '--prompt') { - $ENV{GIT_DIFFTOOL_PROMPT} = 'true'; - delete $ENV{GIT_DIFFTOOL_NO_PROMPT}; - next; + + if ($rmode eq $symlink_mode) { + $symlink{$dst_path}{right} = + $diffrepo->command_oneline('show', "$rsha1"); + } + + if ($lmode ne $null_mode and $status !~ /^C/) { + $lindex .= "$lmode $lsha1\t$src_path\0"; + } + + if ($rmode ne $null_mode) { + my ($use, $wt_sha1) = use_wt_file($repo, $workdir, + $dst_path, $rsha1); + if ($use) { + push @working_tree, $dst_path; + $wtindex .= "$rmode $wt_sha1\t$dst_path\0"; + } else { + $rindex .= "$rmode $rsha1\t$dst_path\0"; + } + } + } + + # Setup temp directories + my $tmpdir = tempdir('git-difftool.XXXXX', CLEANUP => 0, TMPDIR => 1); + my $ldir = "$tmpdir/left"; + my $rdir = "$tmpdir/right"; + mkpath($ldir) or exit_cleanup($tmpdir, 1); + mkpath($rdir) or exit_cleanup($tmpdir, 1); + + # If $GIT_DIR is not set prior to calling 'git update-index' and + # 'git checkout-index', then those commands will fail if difftool + # is called from a directory other than the repo root. + my $must_unset_git_dir = 0; + if (not defined($ENV{GIT_DIR})) { + $must_unset_git_dir = 1; + $ENV{GIT_DIR} = $repo_path; + } + + # Populate the left and right directories based on each index file + my ($inpipe, $ctx); + $ENV{GIT_INDEX_FILE} = "$tmpdir/lindex"; + ($inpipe, $ctx) = + $repo->command_input_pipe(qw(update-index -z --index-info)); + print($inpipe $lindex); + $repo->command_close_pipe($inpipe, $ctx); + + my $rc = system('git', 'checkout-index', '--all', "--prefix=$ldir/"); + exit_cleanup($tmpdir, $rc) if $rc != 0; + + $ENV{GIT_INDEX_FILE} = "$tmpdir/rindex"; + ($inpipe, $ctx) = + $repo->command_input_pipe(qw(update-index -z --index-info)); + print($inpipe $rindex); + $repo->command_close_pipe($inpipe, $ctx); + + $rc = system('git', 'checkout-index', '--all', "--prefix=$rdir/"); + exit_cleanup($tmpdir, $rc) if $rc != 0; + + $ENV{GIT_INDEX_FILE} = "$tmpdir/wtindex"; + ($inpipe, $ctx) = + $repo->command_input_pipe(qw(update-index --info-only -z --index-info)); + print($inpipe $wtindex); + $repo->command_close_pipe($inpipe, $ctx); + + # If $GIT_DIR was explicitly set just for the update/checkout + # commands, then it should be unset before continuing. + delete($ENV{GIT_DIR}) if ($must_unset_git_dir); + delete($ENV{GIT_INDEX_FILE}); + + # Changes in the working tree need special treatment since they are + # not part of the index. Remove any trailing slash from $workdir + # before starting to avoid double slashes in symlink targets. + $workdir =~ s|/$||; + for my $file (@working_tree) { + my $dir = dirname($file); + unless (-d "$rdir/$dir") { + mkpath("$rdir/$dir") or + exit_cleanup($tmpdir, 1); + } + if ($symlinks) { + symlink("$workdir/$file", "$rdir/$file") or + exit_cleanup($tmpdir, 1); + } else { + copy("$workdir/$file", "$rdir/$file") or + exit_cleanup($tmpdir, 1); + + my $mode = stat("$workdir/$file")->mode; + chmod($mode, "$rdir/$file") or + exit_cleanup($tmpdir, 1); + } + } + + # Changes to submodules require special treatment. This loop writes a + # temporary file to both the left and right directories to show the + # change in the recorded SHA1 for the submodule. + for my $path (keys %submodule) { + my $ok; + if (defined($submodule{$path}{left})) { + $ok = write_to_file("$ldir/$path", + "Subproject commit $submodule{$path}{left}"); + } + if (defined($submodule{$path}{right})) { + $ok = write_to_file("$rdir/$path", + "Subproject commit $submodule{$path}{right}"); + } + exit_cleanup($tmpdir, 1) if not $ok; + } + + # Symbolic links require special treatment. The standard "git diff" + # shows only the link itself, not the contents of the link target. + # This loop replicates that behavior. + for my $path (keys %symlink) { + my $ok; + if (defined($symlink{$path}{left})) { + $ok = write_to_file("$ldir/$path", + $symlink{$path}{left}); + } + if (defined($symlink{$path}{right})) { + $ok = write_to_file("$rdir/$path", + $symlink{$path}{right}); + } + exit_cleanup($tmpdir, 1) if not $ok; + } + + return ($ldir, $rdir, $tmpdir, @working_tree); +} + +sub write_to_file +{ + my $path = shift; + my $value = shift; + + # Make sure the path to the file exists + my $dir = dirname($path); + unless (-d "$dir") { + mkpath("$dir") or return 0; + } + + # If the file already exists in that location, delete it. This + # is required in the case of symbolic links. + unlink($path); + + open(my $fh, '>', $path) or return 0; + print($fh $value); + close($fh); + + return 1; +} + +sub main +{ + # parse command-line options. all unrecognized options and arguments + # are passed through to the 'git diff' command. + my %opts = ( + difftool_cmd => undef, + dirdiff => undef, + extcmd => undef, + gui => undef, + help => undef, + prompt => undef, + symlinks => $^O ne 'cygwin' && + $^O ne 'MSWin32' && $^O ne 'msys', + tool_help => undef, + trust_exit_code => undef, + ); + GetOptions('g|gui!' => \$opts{gui}, + 'd|dir-diff' => \$opts{dirdiff}, + 'h' => \$opts{help}, + 'prompt!' => \$opts{prompt}, + 'y' => sub { $opts{prompt} = 0; }, + 'symlinks' => \$opts{symlinks}, + 'no-symlinks' => sub { $opts{symlinks} = 0; }, + 't|tool:s' => \$opts{difftool_cmd}, + 'tool-help' => \$opts{tool_help}, + 'trust-exit-code' => \$opts{trust_exit_code}, + 'no-trust-exit-code' => sub { $opts{trust_exit_code} = 0; }, + 'x|extcmd:s' => \$opts{extcmd}); + + if (defined($opts{help})) { + usage(0); + } + if (defined($opts{tool_help})) { + print_tool_help(); + } + if (defined($opts{difftool_cmd})) { + if (length($opts{difftool_cmd}) > 0) { + $ENV{GIT_DIFF_TOOL} = $opts{difftool_cmd}; + } else { + print "No <tool> given for --tool=<tool>\n"; + usage(1); + } + } + if (defined($opts{extcmd})) { + if (length($opts{extcmd}) > 0) { + $ENV{GIT_DIFFTOOL_EXTCMD} = $opts{extcmd}; + } else { + print "No <cmd> given for --extcmd=<cmd>\n"; + usage(1); + } + } + if ($opts{gui}) { + my $guitool = Git::config('diff.guitool'); + if (defined($guitool) && length($guitool) > 0) { + $ENV{GIT_DIFF_TOOL} = $guitool; } - if ($arg eq '-h' || $arg eq '--help') { - usage(); + } + + if (!defined $opts{trust_exit_code}) { + $opts{trust_exit_code} = Git::config_bool('difftool.trustExitCode'); + } + if ($opts{trust_exit_code}) { + $ENV{GIT_DIFFTOOL_TRUST_EXIT_CODE} = 'true'; + } else { + $ENV{GIT_DIFFTOOL_TRUST_EXIT_CODE} = 'false'; + } + + # In directory diff mode, 'git-difftool--helper' is called once + # to compare the a/b directories. In file diff mode, 'git diff' + # will invoke a separate instance of 'git-difftool--helper' for + # each file that changed. + if (defined($opts{dirdiff})) { + dir_diff($opts{extcmd}, $opts{symlinks}); + } else { + file_diff($opts{prompt}); + } +} + +sub dir_diff +{ + my ($extcmd, $symlinks) = @_; + my $rc; + my $error = 0; + my $repo = Git->repository(); + my $workdir = find_worktree(); + my ($a, $b, $tmpdir, @worktree) = + setup_dir_diff($repo, $workdir, $symlinks); + + if (defined($extcmd)) { + $rc = system($extcmd, $a, $b); + } else { + $ENV{GIT_DIFFTOOL_DIRDIFF} = 'true'; + $rc = system('git', 'difftool--helper', $a, $b); + } + # If the diff including working copy files and those + # files were modified during the diff, then the changes + # should be copied back to the working tree. + # Do not copy back files when symlinks are used and the + # external tool did not replace the original link with a file. + # + # These hashes are loaded lazily since they aren't needed + # in the common case of --symlinks and the difftool updating + # files through the symlink. + my %wt_modified; + my %tmp_modified; + my $indices_loaded = 0; + + for my $file (@worktree) { + next if $symlinks && -l "$b/$file"; + next if ! -f "$b/$file"; + + if (!$indices_loaded) { + %wt_modified = changed_files($repo->repo_path(), + "$tmpdir/wtindex", "$workdir"); + %tmp_modified = changed_files($repo->repo_path(), + "$tmpdir/wtindex", "$b"); + $indices_loaded = 1; + } + + if (exists $wt_modified{$file} and exists $tmp_modified{$file}) { + my $errmsg = "warning: Both files modified: "; + $errmsg .= "'$workdir/$file' and '$b/$file'.\n"; + $errmsg .= "warning: Working tree file has been left.\n"; + $errmsg .= "warning:\n"; + warn $errmsg; + $error = 1; + } elsif (exists $tmp_modified{$file}) { + my $mode = stat("$b/$file")->mode; + copy("$b/$file", "$workdir/$file") or + exit_cleanup($tmpdir, 1); + + chmod($mode, "$workdir/$file") or + exit_cleanup($tmpdir, 1); } - push @command, $arg; } - return @command + if ($error) { + warn "warning: Temporary files exist in '$tmpdir'.\n"; + warn "warning: You may want to cleanup or recover these.\n"; + exit(1); + } else { + exit_cleanup($tmpdir, $rc); + } } -setup_environment(); +sub file_diff +{ + my ($prompt) = @_; + + if (defined($prompt)) { + if ($prompt) { + $ENV{GIT_DIFFTOOL_PROMPT} = 'true'; + } else { + $ENV{GIT_DIFFTOOL_NO_PROMPT} = 'true'; + } + } + + $ENV{GIT_PAGER} = ''; + $ENV{GIT_EXTERNAL_DIFF} = 'git-difftool--helper'; + + # ActiveState Perl for Win32 does not implement POSIX semantics of + # exec* system call. It just spawns the given executable and finishes + # the starting program, exiting with code 0. + # system will at least catch the errors returned by git diff, + # allowing the caller of git difftool better handling of failures. + my $rc = system('git', 'diff', @ARGV); + exit($rc | ($rc >> 8)); +} -# ActiveState Perl for Win32 does not implement POSIX semantics of -# exec* system call. It just spawns the given executable and finishes -# the starting program, exiting with code 0. -# system will at least catch the errors returned by git diff, -# allowing the caller of git difftool better handling of failures. -my $rc = system(generate_command()); -exit($rc | ($rc >> 8)); +main(); |