difftool: teach difftool to handle directory diffs
[git] / git-difftool.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.
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;
32         print << 'USAGE';
33 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.
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; },
239         't|tool:s' => \$difftool_cmd,
240         'x|extcmd:s' => \$extcmd);
241
242 if (defined($help)) {
243         usage(0);
244 }
245 if (defined($difftool_cmd)) {
246         if (length($difftool_cmd) > 0) {
247                 $ENV{GIT_DIFF_TOOL} = $difftool_cmd;
248         } else {
249                 print "No <tool> given for --tool=<tool>\n";
250                 usage(1);
251         }
252 }
253 if (defined($extcmd)) {
254         if (length($extcmd) > 0) {
255                 $ENV{GIT_DIFFTOOL_EXTCMD} = $extcmd;
256         } else {
257                 print "No <cmd> given for --extcmd=<cmd>\n";
258                 usage(1);
259         }
260 }
261 if ($gui) {
262         my $guitool = '';
263         $guitool = Git::config('diff.guitool');
264         if (length($guitool) > 0) {
265                 $ENV{GIT_DIFF_TOOL} = $guitool;
266         }
267 }
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 {
278                 $ENV{GIT_DIFFTOOL_DIRDIFF} = 'true';
279                 $rc = system('git', 'difftool--helper', $a, $b);
280         }
281
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 }