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