difftool: teach difftool to handle directory diffs

When 'difftool' is called to compare a range of commits that modify more than one file, it opens a separate instance of the diff tool for each file that changed. The new '--dir-diff' option copies all the modified files to a temporary location and runs a directory diff on them in a single instance of the diff tool. Signed-off-by: Tim Henigan <tim.henigan@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Tim Henigan committed Apr 23, 2012 at 14:23 UTC 7e0abcec103b3649943b236881cf88e8fd6cf3a4
4 files changed +303 -27
Documentation/git-difftool.txt
+6
@@ -19,6 +19,12 @@ linkgit:git-diff[1].
19
20 OPTIONS
21 -------
22 +-d::
23 +--dir-diff::
24 + Copy the modified files to a temporary location and perform
25 + a directory diff on them. This mode never prompts before
26 + launching the diff tool.
27 +
28 -y::
29 --no-prompt::
30 Do not prompt before launching a diff tool.
git-difftool--helper.sh
+13 -6
@@ -73,9 +73,16 @@ then
73 fi
74 fi
75
76 -# Launch the merge tool on each path provided by 'git diff'
77 -while test $# -gt 6
78 -do
79 - launch_merge_tool "$1" "$2" "$5"
80 - shift 7
81 -done
76 +if test -n "$GIT_DIFFTOOL_DIRDIFF"
77 +then
78 + LOCAL="$1"
79 + REMOTE="$2"
80 + run_merge_tool "$merge_tool" false
81 +else
82 + # Launch the merge tool on each path provided by 'git diff'
83 + while test $# -gt 6
84 + do
85 + launch_merge_tool "$1" "$2" "$5"
86 + shift 7
87 + done
88 +fi
git-difftool.perl
+245 -21
@@ -1,21 +1,31 @@
1 -#!/usr/bin/env perl
1 +#!/usr/bin/perl
2 # Copyright (c) 2009, 2010 David Aguilar
3 +# Copyright (c) 2012 Tim Henigan
4 #
5 # This is a wrapper around the GIT_EXTERNAL_DIFF-compatible
6 # git-difftool--helper script.
7 #
8 # This script exports GIT_EXTERNAL_DIFF and GIT_PAGER for use by git.
8 -# GIT_DIFFTOOL_NO_PROMPT, GIT_DIFFTOOL_PROMPT, and GIT_DIFF_TOOL
9 -# are exported for use by git-difftool--helper.
9 +# The GIT_DIFF* variables are exported for use by git-difftool--helper.
10 #
11 # Any arguments that are unknown to this script are forwarded to 'git diff'.
12
13 use 5.008;
14 use strict;
15 use warnings;
16 +use File::Basename qw(dirname);
17 +use File::Copy;
18 +use File::stat;
19 +use File::Path qw(mkpath);
20 +use File::Temp qw(tempdir);
21 use Getopt::Long qw(:config pass_through);
22 use Git;
23
24 +my @working_tree;
25 +my $rc;
26 +my $repo = Git->repository();
27 +my $repo_path = $repo->repo_path();
28 +
29 sub usage
30 {
31 my $exitcode = shift;
@@ -24,15 +34,205 @@ usage: git difftool [-t|--tool=<tool>]
34 [-x|--extcmd=<cmd>]
35 [-g|--gui] [--no-gui]
36 [--prompt] [-y|--no-prompt]
37 + [-d|--dir-diff]
38 ['git diff' options]
39 USAGE
40 exit($exitcode);
41 }
42
43 +sub find_worktree
44 +{
45 + # Git->repository->wc_path() does not honor changes to the working
46 + # tree location made by $ENV{GIT_WORK_TREE} or the 'core.worktree'
47 + # config variable.
48 + my $worktree;
49 + my $env_worktree = $ENV{GIT_WORK_TREE};
50 + my $core_worktree = Git::config('core.worktree');
51 +
52 + if (defined($env_worktree) and (length($env_worktree) > 0)) {
53 + $worktree = $env_worktree;
54 + } elsif (defined($core_worktree) and (length($core_worktree) > 0)) {
55 + $worktree = $core_worktree;
56 + } else {
57 + $worktree = $repo->wc_path();
58 + }
59 +
60 + return $worktree;
61 +}
62 +
63 +my $workdir = find_worktree();
64 +
65 +sub setup_dir_diff
66 +{
67 + # Run the diff; exit immediately if no diff found
68 + # 'Repository' and 'WorkingCopy' must be explicitly set to insure that
69 + # if $GIT_DIR and $GIT_WORK_TREE are set in ENV, they are actually used
70 + # by Git->repository->command*.
71 + my $diffrepo = Git->repository(Repository => $repo_path, WorkingCopy => $workdir);
72 + my $diffrtn = $diffrepo->command_oneline('diff', '--raw', '--no-abbrev', '-z', @ARGV);
73 + exit(0) if (length($diffrtn) == 0);
74 +
75 + # Setup temp directories
76 + my $tmpdir = tempdir('git-diffall.XXXXX', CLEANUP => 1, TMPDIR => 1);
77 + my $ldir = "$tmpdir/left";
78 + my $rdir = "$tmpdir/right";
79 + mkpath($ldir) or die $!;
80 + mkpath($rdir) or die $!;
81 +
82 + # Build index info for left and right sides of the diff
83 + my $submodule_mode = '160000';
84 + my $symlink_mode = '120000';
85 + my $null_mode = '0' x 6;
86 + my $null_sha1 = '0' x 40;
87 + my $lindex = '';
88 + my $rindex = '';
89 + my %submodule;
90 + my %symlink;
91 + my @rawdiff = split('\0', $diffrtn);
92 +
93 + my $i = 0;
94 + while ($i < $#rawdiff) {
95 + if ($rawdiff[$i] =~ /^::/) {
96 + print "Combined diff formats ('-c' and '--cc') are not supported in directory diff mode.\n";
97 + exit(1);
98 + }
99 +
100 + my ($lmode, $rmode, $lsha1, $rsha1, $status) = split(' ', substr($rawdiff[$i], 1));
101 + my $src_path = $rawdiff[$i + 1];
102 + my $dst_path;
103 +
104 + if ($status =~ /^[CR]/) {
105 + $dst_path = $rawdiff[$i + 2];
106 + $i += 3;
107 + } else {
108 + $dst_path = $src_path;
109 + $i += 2;
110 + }
111 +
112 + if (($lmode eq $submodule_mode) or ($rmode eq $submodule_mode)) {
113 + $submodule{$src_path}{left} = $lsha1;
114 + if ($lsha1 ne $rsha1) {
115 + $submodule{$dst_path}{right} = $rsha1;
116 + } else {
117 + $submodule{$dst_path}{right} = "$rsha1-dirty";
118 + }
119 + next;
120 + }
121 +
122 + if ($lmode eq $symlink_mode) {
123 + $symlink{$src_path}{left} = $diffrepo->command_oneline('show', "$lsha1");
124 + }
125 +
126 + if ($rmode eq $symlink_mode) {
127 + $symlink{$dst_path}{right} = $diffrepo->command_oneline('show', "$rsha1");
128 + }
129 +
130 + if (($lmode ne $null_mode) and ($status !~ /^C/)) {
131 + $lindex .= "$lmode $lsha1\t$src_path\0";
132 + }
133 +
134 + if ($rmode ne $null_mode) {
135 + if ($rsha1 ne $null_sha1) {
136 + $rindex .= "$rmode $rsha1\t$dst_path\0";
137 + } else {
138 + push(@working_tree, $dst_path);
139 + }
140 + }
141 + }
142 +
143 + # If $GIT_DIR is not set prior to calling 'git update-index' and
144 + # 'git checkout-index', then those commands will fail if difftool
145 + # is called from a directory other than the repo root.
146 + my $must_unset_git_dir = 0;
147 + if (not defined($ENV{GIT_DIR})) {
148 + $must_unset_git_dir = 1;
149 + $ENV{GIT_DIR} = $repo_path;
150 + }
151 +
152 + # Populate the left and right directories based on each index file
153 + my ($inpipe, $ctx);
154 + $ENV{GIT_INDEX_FILE} = "$tmpdir/lindex";
155 + ($inpipe, $ctx) = $repo->command_input_pipe(qw/update-index -z --index-info/);
156 + print($inpipe $lindex);
157 + $repo->command_close_pipe($inpipe, $ctx);
158 + $rc = system('git', 'checkout-index', '--all', "--prefix=$ldir/");
159 + exit($rc | ($rc >> 8)) if ($rc != 0);
160 +
161 + $ENV{GIT_INDEX_FILE} = "$tmpdir/rindex";
162 + ($inpipe, $ctx) = $repo->command_input_pipe(qw/update-index -z --index-info/);
163 + print($inpipe $rindex);
164 + $repo->command_close_pipe($inpipe, $ctx);
165 + $rc = system('git', 'checkout-index', '--all', "--prefix=$rdir/");
166 + exit($rc | ($rc >> 8)) if ($rc != 0);
167 +
168 + # If $GIT_DIR was explicitly set just for the update/checkout
169 + # commands, then it should be unset before continuing.
170 + delete($ENV{GIT_DIR}) if ($must_unset_git_dir);
171 + delete($ENV{GIT_INDEX_FILE});
172 +
173 + # Changes in the working tree need special treatment since they are
174 + # not part of the index
175 + for my $file (@working_tree) {
176 + my $dir = dirname($file);
177 + unless (-d "$rdir/$dir") {
178 + mkpath("$rdir/$dir") or die $!;
179 + }
180 + copy("$workdir/$file", "$rdir/$file") or die $!;
181 + chmod(stat("$workdir/$file")->mode, "$rdir/$file") or die $!;
182 + }
183 +
184 + # Changes to submodules require special treatment. This loop writes a
185 + # temporary file to both the left and right directories to show the
186 + # change in the recorded SHA1 for the submodule.
187 + for my $path (keys %submodule) {
188 + if (defined($submodule{$path}{left})) {
189 + write_to_file("$ldir/$path", "Subproject commit $submodule{$path}{left}");
190 + }
191 + if (defined($submodule{$path}{right})) {
192 + write_to_file("$rdir/$path", "Subproject commit $submodule{$path}{right}");
193 + }
194 + }
195 +
196 + # Symbolic links require special treatment. The standard "git diff"
197 + # shows only the link itself, not the contents of the link target.
198 + # This loop replicates that behavior.
199 + for my $path (keys %symlink) {
200 + if (defined($symlink{$path}{left})) {
201 + write_to_file("$ldir/$path", $symlink{$path}{left});
202 + }
203 + if (defined($symlink{$path}{right})) {
204 + write_to_file("$rdir/$path", $symlink{$path}{right});
205 + }
206 + }
207 +
208 + return ($ldir, $rdir);
209 +}
210 +
211 +sub write_to_file
212 +{
213 + my $path = shift;
214 + my $value = shift;
215 +
216 + # Make sure the path to the file exists
217 + my $dir = dirname($path);
218 + unless (-d "$dir") {
219 + mkpath("$dir") or die $!;
220 + }
221 +
222 + # If the file already exists in that location, delete it. This
223 + # is required in the case of symbolic links.
224 + unlink("$path");
225 +
226 + open(my $fh, '>', "$path") or die $!;
227 + print($fh $value);
228 + close($fh);
229 +}
230 +
231 # parse command-line options. all unrecognized options and arguments
232 # are passed through to the 'git diff' command.
34 -my ($difftool_cmd, $extcmd, $gui, $help, $prompt);
233 +my ($difftool_cmd, $dirdiff, $extcmd, $gui, $help, $prompt);
234 GetOptions('g|gui!' => \$gui,
235 + 'd|dir-diff' => \$dirdiff,
236 'h' => \$help,
237 'prompt!' => \$prompt,
238 'y' => sub { $prompt = 0; },
@@ -59,28 +259,52 @@ if (defined($extcmd)) {
259 }
260 }
261 if ($gui) {
62 - my $guitool = "";
262 + my $guitool = '';
263 $guitool = Git::config('diff.guitool');
264 if (length($guitool) > 0) {
265 $ENV{GIT_DIFF_TOOL} = $guitool;
266 }
267 }
68 -if (defined($prompt)) {
69 - if ($prompt) {
70 - $ENV{GIT_DIFFTOOL_PROMPT} = 'true';
268 +
269 +# In directory diff mode, 'git-difftool--helper' is called once
270 +# to compare the a/b directories. In file diff mode, 'git diff'
271 +# will invoke a separate instance of 'git-difftool--helper' for
272 +# each file that changed.
273 +if (defined($dirdiff)) {
274 + my ($a, $b) = setup_dir_diff();
275 + if (defined($extcmd)) {
276 + $rc = system($extcmd, $a, $b);
277 } else {
72 - $ENV{GIT_DIFFTOOL_NO_PROMPT} = 'true';
278 + $ENV{GIT_DIFFTOOL_DIRDIFF} = 'true';
279 + $rc = system('git', 'difftool--helper', $a, $b);
280 }
74 -}
281
76 -$ENV{GIT_PAGER} = '';
77 -$ENV{GIT_EXTERNAL_DIFF} = 'git-difftool--helper';
78 -my @command = ('git', 'diff', @ARGV);
79 -
80 -# ActiveState Perl for Win32 does not implement POSIX semantics of
81 -# exec* system call. It just spawns the given executable and finishes
82 -# the starting program, exiting with code 0.
83 -# system will at least catch the errors returned by git diff,
84 -# allowing the caller of git difftool better handling of failures.
85 -my $rc = system(@command);
86 -exit($rc | ($rc >> 8));
282 + exit($rc | ($rc >> 8)) if ($rc != 0);
283 +
284 + # If the diff including working copy files and those
285 + # files were modified during the diff, then the changes
286 + # should be copied back to the working tree
287 + for my $file (@working_tree) {
288 + copy("$b/$file", "$workdir/$file") or die $!;
289 + chmod(stat("$b/$file")->mode, "$workdir/$file") or die $!;
290 + }
291 +} else {
292 + if (defined($prompt)) {
293 + if ($prompt) {
294 + $ENV{GIT_DIFFTOOL_PROMPT} = 'true';
295 + } else {
296 + $ENV{GIT_DIFFTOOL_NO_PROMPT} = 'true';
297 + }
298 + }
299 +
300 + $ENV{GIT_PAGER} = '';
301 + $ENV{GIT_EXTERNAL_DIFF} = 'git-difftool--helper';
302 +
303 + # ActiveState Perl for Win32 does not implement POSIX semantics of
304 + # exec* system call. It just spawns the given executable and finishes
305 + # the starting program, exiting with code 0.
306 + # system will at least catch the errors returned by git diff,
307 + # allowing the caller of git difftool better handling of failures.
308 + my $rc = system('git', 'diff', @ARGV);
309 + exit($rc | ($rc >> 8));
310 +}
t/t7800-difftool.sh
+39
@@ -319,4 +319,43 @@ test_expect_success PERL 'say no to the second file' '
319 echo "$diff" | stdin_doesnot_contain br2
320 '
321
322 +test_expect_success PERL 'setup change in subdirectory' '
323 + git checkout master &&
324 + mkdir sub &&
325 + echo master >sub/sub &&
326 + git add sub/sub &&
327 + git commit -m "added sub/sub" &&
328 + echo test >>file &&
329 + echo test >>sub/sub &&
330 + git add . &&
331 + git commit -m "modified both"
332 +'
333 +
334 +test_expect_success PERL 'difftool -d' '
335 + diff=$(git difftool -d --extcmd ls branch) &&
336 + echo "$diff" | stdin_contains sub &&
337 + echo "$diff" | stdin_contains file
338 +'
339 +
340 +test_expect_success PERL 'difftool --dir-diff' '
341 + diff=$(git difftool --dir-diff --extcmd ls branch) &&
342 + echo "$diff" | stdin_contains sub &&
343 + echo "$diff" | stdin_contains file
344 +'
345 +
346 +test_expect_success PERL 'difftool --dir-diff ignores --prompt' '
347 + diff=$(git difftool --dir-diff --prompt --extcmd ls branch) &&
348 + echo "$diff" | stdin_contains sub &&
349 + echo "$diff" | stdin_contains file
350 +'
351 +
352 +test_expect_success PERL 'difftool --dir-diff from subdirectory' '
353 + (
354 + cd sub &&
355 + diff=$(git difftool --dir-diff --extcmd ls branch) &&
356 + echo "$diff" | stdin_contains sub &&
357 + echo "$diff" | stdin_contains file
358 + )
359 +'
360 +
361 test_done