diff --git a/Documentation/git-difftool.txt b/Documentation/git-difftool.txt index fe38f667f9..31fc2e3aed 100644 --- a/Documentation/git-difftool.txt +++ b/Documentation/git-difftool.txt @@ -19,6 +19,12 @@ linkgit:git-diff[1]. OPTIONS ------- +-d:: +--dir-diff:: + Copy the modified files to a temporary location and perform + a directory diff on them. This mode never prompts before + launching the diff tool. + -y:: --no-prompt:: Do not prompt before launching a diff tool. @@ -30,11 +36,9 @@ OPTIONS -t :: --tool=:: - Use the diff tool specified by . - Valid diff tools are: - araxis, bc3, deltawalker, diffuse, emerge, ecmerge, gvimdiff, - kdiff3, kompare, meld, opendiff, p4merge, tkdiff, vimdiff and - xxdiff. + Use the diff tool specified by . Valid values include + emerge, kompare, meld, and vimdiff. Run `git difftool --tool-help` + for the list of valid settings. + If a diff tool is not specified, 'git difftool' will use the configuration variable `diff.tool`. If the @@ -62,6 +66,9 @@ of the diff post-image. `$MERGED` is the name of the file which is being compared. `$BASE` is provided for compatibility with custom merge tool commands and has the same value as `$MERGED`. +--tool-help:: + Print a list of diff tools that may be used with `--tool`. + -x :: --extcmd=:: Specify a custom command for viewing diffs. diff --git a/git-difftool--helper.sh b/git-difftool--helper.sh index e6558d1010..3d0fe0cd93 100755 --- a/git-difftool--helper.sh +++ b/git-difftool--helper.sh @@ -73,9 +73,16 @@ then fi fi -# Launch the merge tool on each path provided by 'git diff' -while test $# -gt 6 -do - launch_merge_tool "$1" "$2" "$5" - shift 7 -done +if test -n "$GIT_DIFFTOOL_DIRDIFF" +then + LOCAL="$1" + REMOTE="$2" + run_merge_tool "$merge_tool" false +else + # Launch the merge tool on each path provided by 'git diff' + while test $# -gt 6 + do + launch_merge_tool "$1" "$2" "$5" + shift 7 + done +fi diff --git a/git-difftool.perl b/git-difftool.perl index 09b65f1770..ae1e0525d8 100755 --- a/git-difftool.perl +++ b/git-difftool.perl @@ -1,121 +1,361 @@ -#!/usr/bin/env perl +#!/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 File::Basename qw(dirname); +use File::Copy; +use File::Find; +use File::stat; +use File::Path qw(mkpath); +use File::Temp qw(tempdir); +use Getopt::Long qw(:config pass_through); +use Git; -require Git; - -my $DIR = abs_path(dirname($0)); - +my @tools; +my @working_tree; +my $rc; +my $repo = Git->repository(); +my $repo_path = $repo->repo_path(); sub usage { + my $exitcode = shift; print << 'USAGE'; -usage: git difftool [-t|--tool=] [-x|--extcmd=] - [-y|--no-prompt] [-g|--gui] +usage: git difftool [-t|--tool=] [--tool-help] + [-x|--extcmd=] + [-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. + my $worktree; + my $env_worktree = $ENV{GIT_WORK_TREE}; + my $core_worktree = Git::config('core.worktree'); + + if (defined($env_worktree) and (length($env_worktree) > 0)) { + $worktree = $env_worktree; + } elsif (defined($core_worktree) and (length($core_worktree) > 0)) { + $worktree = $core_worktree; + } else { + $worktree = $repo->wc_path(); + } + + return $worktree; } -sub exe +my $workdir = find_worktree(); + +sub filter_tool_scripts { - my $exe = shift; - if ($^O eq 'MSWin32' || $^O eq 'msys') { - return "$exe.exe"; + if (-d $_) { + if ($_ ne ".") { + # Ignore files in subdirectories + $File::Find::prune = 1; + } + } else { + if ((-f $_) && ($_ ne "defaults")) { + push(@tools, $_); + } } - return $exe; } -sub generate_command +sub print_tool_help { - my @command = (exe('git'), 'diff'); - my $skip_next = 0; - my $idx = -1; - my $prompt = ''; - for my $arg (@ARGV) { - $idx++; - if ($skip_next) { - $skip_next = 0; - next; + my ($cmd, @found, @notfound); + my $gitpath = Git::exec_path(); + + find(\&filter_tool_scripts, "$gitpath/mergetools"); + + foreach my $tool (@tools) { + $cmd = "TOOL_MODE=diff"; + $cmd .= ' && . "$(git --exec-path)/git-mergetool--lib"'; + $cmd .= " && get_merge_tool_path $tool >/dev/null 2>&1"; + $cmd .= " && can_diff >/dev/null 2>&1"; + if (system('sh', '-c', $cmd) == 0) { + push(@found, $tool); + } else { + push(@notfound, $tool); } - if ($arg eq '-t' || $arg eq '--tool') { - usage() if $#ARGV <= $idx; - $ENV{GIT_DIFF_TOOL} = $ARGV[$idx + 1]; - $skip_next = 1; - next; + } + + print "'git difftool --tool=' may be set to one of the following:\n"; + print "\t$_\n" for (sort(@found)); + + print "\nThe following tools are valid, but not currently available:\n"; + print "\t$_\n" for (sort(@notfound)); + + print "\nNOTE: Some of the tools listed above only work in a windowed\n"; + print "environment. If run in a terminal-only session, they will fail.\n"; + + exit(0); +} + +sub setup_dir_diff +{ + # 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 $diffrepo = Git->repository(Repository => $repo_path, WorkingCopy => $workdir); + my $diffrtn = $diffrepo->command_oneline('diff', '--raw', '--no-abbrev', '-z', @ARGV); + exit(0) if (length($diffrtn) == 0); + + # Setup temp directories + my $tmpdir = tempdir('git-diffall.XXXXX', CLEANUP => 1, TMPDIR => 1); + my $ldir = "$tmpdir/left"; + my $rdir = "$tmpdir/right"; + mkpath($ldir) or die $!; + mkpath($rdir) or die $!; + + # 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 %submodule; + my %symlink; + my @rawdiff = split('\0', $diffrtn); + + my $i = 0; + while ($i < $#rawdiff) { + if ($rawdiff[$i] =~ /^::/) { + print "Combined diff formats ('-c' and '--cc') are not supported in directory diff mode.\n"; + exit(1); } - if ($arg =~ /^--tool=/) { - $ENV{GIT_DIFF_TOOL} = substr($arg, 7); - 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 eq '-x' || $arg eq '--extcmd') { - usage() if $#ARGV <= $idx; - $ENV{GIT_DIFFTOOL_EXTCMD} = $ARGV[$idx + 1]; - $skip_next = 1; + + 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 =~ /^--extcmd=/) { - $ENV{GIT_DIFFTOOL_EXTCMD} = substr($arg, 9); - next; + + if ($lmode eq $symlink_mode) { + $symlink{$src_path}{left} = $diffrepo->command_oneline('show', "$lsha1"); } - if ($arg eq '-g' || $arg eq '--gui') { - eval { - my $tool = Git::command_oneline('config', - 'diff.guitool'); - if (length($tool)) { - $ENV{GIT_DIFF_TOOL} = $tool; - } - }; - next; + + if ($rmode eq $symlink_mode) { + $symlink{$dst_path}{right} = $diffrepo->command_oneline('show', "$rsha1"); } - if ($arg eq '-y' || $arg eq '--no-prompt') { - $prompt = 'no'; - next; + + if (($lmode ne $null_mode) and ($status !~ /^C/)) { + $lindex .= "$lmode $lsha1\t$src_path\0"; } - if ($arg eq '--prompt') { - $prompt = 'yes'; - next; + + if ($rmode ne $null_mode) { + if ($rsha1 ne $null_sha1) { + $rindex .= "$rmode $rsha1\t$dst_path\0"; + } else { + push(@working_tree, $dst_path); + } } - if ($arg eq '-h') { - usage(); + } + + # 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); + $rc = system('git', 'checkout-index', '--all', "--prefix=$ldir/"); + exit($rc | ($rc >> 8)) 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($rc | ($rc >> 8)) if ($rc != 0); + + # 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 + for my $file (@working_tree) { + my $dir = dirname($file); + unless (-d "$rdir/$dir") { + mkpath("$rdir/$dir") or die $!; + } + copy("$workdir/$file", "$rdir/$file") or die $!; + chmod(stat("$workdir/$file")->mode, "$rdir/$file") or die $!; + } + + # 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) { + if (defined($submodule{$path}{left})) { + write_to_file("$ldir/$path", "Subproject commit $submodule{$path}{left}"); + } + if (defined($submodule{$path}{right})) { + write_to_file("$rdir/$path", "Subproject commit $submodule{$path}{right}"); + } + } + + # 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) { + if (defined($symlink{$path}{left})) { + write_to_file("$ldir/$path", $symlink{$path}{left}); + } + if (defined($symlink{$path}{right})) { + write_to_file("$rdir/$path", $symlink{$path}{right}); } - push @command, $arg; } - if ($prompt eq 'yes') { - $ENV{GIT_DIFFTOOL_PROMPT} = 'true'; - } elsif ($prompt eq 'no') { - $ENV{GIT_DIFFTOOL_NO_PROMPT} = 'true'; + + return ($ldir, $rdir); +} + +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 die $!; } - return @command + + # 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 die $!; + print($fh $value); + close($fh); } -setup_environment(); +# parse command-line options. all unrecognized options and arguments +# are passed through to the 'git diff' command. +my ($difftool_cmd, $dirdiff, $extcmd, $gui, $help, $prompt, $tool_help); +GetOptions('g|gui!' => \$gui, + 'd|dir-diff' => \$dirdiff, + 'h' => \$help, + 'prompt!' => \$prompt, + 'y' => sub { $prompt = 0; }, + 't|tool:s' => \$difftool_cmd, + 'tool-help' => \$tool_help, + 'x|extcmd:s' => \$extcmd); -# 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)); +if (defined($help)) { + usage(0); +} +if (defined($tool_help)) { + print_tool_help(); +} +if (defined($difftool_cmd)) { + if (length($difftool_cmd) > 0) { + $ENV{GIT_DIFF_TOOL} = $difftool_cmd; + } else { + print "No given for --tool=\n"; + usage(1); + } +} +if (defined($extcmd)) { + if (length($extcmd) > 0) { + $ENV{GIT_DIFFTOOL_EXTCMD} = $extcmd; + } else { + print "No given for --extcmd=\n"; + usage(1); + } +} +if ($gui) { + my $guitool = ''; + $guitool = Git::config('diff.guitool'); + if (length($guitool) > 0) { + $ENV{GIT_DIFF_TOOL} = $guitool; + } +} + +# 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($dirdiff)) { + my ($a, $b) = setup_dir_diff(); + if (defined($extcmd)) { + $rc = system($extcmd, $a, $b); + } else { + $ENV{GIT_DIFFTOOL_DIRDIFF} = 'true'; + $rc = system('git', 'difftool--helper', $a, $b); + } + + exit($rc | ($rc >> 8)) if ($rc != 0); + + # 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 + for my $file (@working_tree) { + copy("$b/$file", "$workdir/$file") or die $!; + chmod(stat("$b/$file")->mode, "$workdir/$file") or die $!; + } +} else { + 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)); +} diff --git a/t/t7800-difftool.sh b/t/t7800-difftool.sh index 2763d795f0..9c3e997b9d 100755 --- a/t/t7800-difftool.sh +++ b/t/t7800-difftool.sh @@ -105,6 +105,19 @@ test_expect_success PERL 'difftool honors --gui' ' restore_test_defaults ' +test_expect_success PERL 'difftool --gui last setting wins' ' + git config diff.guitool bogus-tool && + git difftool --no-prompt --gui --no-gui && + + git config merge.tool bogus-tool && + git config diff.tool bogus-tool && + git config diff.guitool test-tool && + diff=$(git difftool --no-prompt --no-gui --gui branch) && + test "$diff" = "branch" && + + restore_test_defaults +' + test_expect_success PERL 'difftool --gui works without configured diff.guitool' ' git config diff.tool test-tool && @@ -317,4 +330,48 @@ test_expect_success PERL 'say no to the second file' ' echo "$diff" | stdin_doesnot_contain br2 ' +test_expect_success PERL 'difftool --tool-help' ' + tool_help=$(git difftool --tool-help) && + echo "$tool_help" | stdin_contains tool +' + +test_expect_success PERL 'setup change in subdirectory' ' + git checkout master && + mkdir sub && + echo master >sub/sub && + git add sub/sub && + git commit -m "added sub/sub" && + echo test >>file && + echo test >>sub/sub && + git add . && + git commit -m "modified both" +' + +test_expect_success PERL 'difftool -d' ' + diff=$(git difftool -d --extcmd ls branch) && + echo "$diff" | stdin_contains sub && + echo "$diff" | stdin_contains file +' + +test_expect_success PERL 'difftool --dir-diff' ' + diff=$(git difftool --dir-diff --extcmd ls branch) && + echo "$diff" | stdin_contains sub && + echo "$diff" | stdin_contains file +' + +test_expect_success PERL 'difftool --dir-diff ignores --prompt' ' + diff=$(git difftool --dir-diff --prompt --extcmd ls branch) && + echo "$diff" | stdin_contains sub && + echo "$diff" | stdin_contains file +' + +test_expect_success PERL 'difftool --dir-diff from subdirectory' ' + ( + cd sub && + diff=$(git difftool --dir-diff --extcmd ls branch) && + echo "$diff" | stdin_contains sub && + echo "$diff" | stdin_contains file + ) +' + test_done