Skip to content

Commit

Permalink
Completely rewrote git-multidiff.
Browse files Browse the repository at this point in the history
Now instead of running git-difftool n+1 times we run it once. The old
implementation used the files created by git-difftool directly, but
since it deletes the temporary files as soon as it completes, we
effectively had to keep it running to keep the files alive. This meant
having O(n) pipes open. It also turned out that it didn't work correctly
with -M (show moves/renames) as the --name-only flag behaves...
inconsistently in this mode.

The new implementation has a helper tool that copies (or hardlinks, when
possible) the files that appear to be temporary. This means we only run
git-difftool once, and we don't rely on the (flaky) --name-only flag so
-M actually works as expected. (Well, mostly. Currently we're copying
/dev/null, which is kind of weird.)
  • Loading branch information
xenomachina committed Feb 10, 2012
1 parent 162e043 commit 0087d68
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 48 deletions.
71 changes: 71 additions & 0 deletions bin/_git-multidiff-helper
@@ -0,0 +1,71 @@
#!/usr/bin/env python

import errno
import os
import shutil
import stat
import sys

"""
Helper used by git-multidiff.
This script is used as the "difftool" when invoking git-difftool. It
creates copies of (or hardlinks to) files that appear to be temporary,
and records the names of the files that git-multidiff will need to diff.
"""

# I can't believe this isn't already in shutil.
def mkdirp(dir):
"""
Python version of 'mkdir -p'.
Creates directory, including parent directories. Doesn't get upset if
directory already exists.
"""
try:
os.makedirs(dir)
except OSError as e:
if e.errno != errno.EEXIST:
raise

def main(argv):
DEBUG = False
# TODO: find some way to pass fatal errors up to top-level?
tmpdir = os.environ['GIT_MULTIDIFF_TEMP']
out = open(os.path.join(tmpdir, 'args'), 'a')
for fnam in argv[1:]:
# TODO: treat files in /dev/ like files in workspace?
if os.path.isabs(fnam):
if fnam.startswith('//'):
# A leading double-slash has system defined meaning in POSIX so
# treat such files as different from others just in case.
dest = os.path.join(tmpdir, 'dslash')
else:
dest = os.path.join(tmpdir, 'slash')
dest += fnam
mkdirp(os.path.dirname(dest))
try:
os.link(fnam, dest)
if DEBUG: print 'ln %r -> %r' % (fnam, dest)
except OSError:
# TODO: this doesn't work with multiple occurrences of the same
# file (in particular, /dev/null) as we make the destination
# read-only. This should get fixed once the perm stuff is moved.
shutil.copy2(fnam, dest)
if DEBUG: print 'cp %r -> %r' % (fnam, dest)
try:
# Make copy read-only
perms = os.stat(dest).st_mode \
& ~(stat.S_IWGRP | stat.S_IWOTH | stat.S_IWUSR)
os.chmod(dest, perms)
if DEBUG: print 'chmod a-r %r' % dest
except IOError:
pass # we tried, but it isn't critical that we succeed
else:
dest = fnam
out.write(dest)
out.write('\0')
out.close()

if __name__ == '__main__':
main(sys.argv)
81 changes: 33 additions & 48 deletions bin/git-multidiff
@@ -1,60 +1,45 @@
#!/usr/bin/env python

import sys
import commands
import os
import shutil
import subprocess
import sys
import tempfile

"""
Like git-difftool, but executes all diffs in parallel.
TODO:
- run git diff only once instead of n+1 times. The current setup is
flakey when moves/renames happen and -M is passed.
- parameterize multi-diff tool
"""

def cmd_lines(*args, **kwargs):
"""
Calls subprocess.check_output, but returns result as a list of lines,
with trailing newlines removed. A newline at the end of the file is
also ignored.
"""
output = subprocess.check_output(*args, **kwargs).split('\n')
# deal with trailing newline
if not output[-1]:
del output[-1]
return output

# git diff --name-only "$@" | while read filename; do
# git difftool "$@" --no-prompt "$filename" &
def main(argv):
diff_fnams = cmd_lines(['git', 'diff', '--name-only'] + argv[1:])
children = []
pairs = []
for fnam in diff_fnams:
child = subprocess.Popen(
['git', 'difftool'] + argv[1:] + ['--no-prompt', '-x', 'echo-and-hang', '--', fnam],
bufsize=1,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE)
pairs.append(child.stdout.readline()[:-1].split('\000'))
children.append(child)

args = []
for old, new in pairs:
args.extend((old, new))
# status = subprocess.call(
# ['mvim', '-f', '+set lines=999 columns=999', '-o']
# + args
# + ['+silent call TabMultiDiff()'])
status = subprocess.call(
['mvim', '-f', '-c', 'silent call TabMultiDiffMaximized()']
+ args)

# Terminate children gracefully...
for child in children:
child.stdin.write('\n')
child.stdin.flush()
child.wait()
try:
tool = subprocess.check_output(['git', 'config', '--get', 'multidiff.tool']).strip()
except subprocess.CalledProcessError as exc:
print >>sys.stderr, \
'Error: %r returned status %r' % (' '.join(exc.cmd), exc.returncode)
sys.exit(1)

tmpdir = tempfile.mkdtemp()
try:
os.environ['GIT_MULTIDIFF_TEMP'] = tmpdir
argsfile = os.path.join(tmpdir, 'args')
open(argsfile, 'w').close()
subprocess.check_call(['git', 'difftool', '-y', '-x',
'_git-multidiff-helper'] + argv[1:])
difftool_args = [x for x in
open(argsfile).read().split('\0')
if x]
assert not (len(difftool_args) % 2), \
"Expected even number of files, but got %d" % len(difftool_args)
# TODO: move chmod stuff here, check that link count == 1 and in tmpdir.

if difftool_args:
# Instead of using `subprocess.check_call([tool] + difftool_args)`
# we use os.system. This makes it possible for the tool to contain
# flags (or potentially other stuff evaluated by the shell).
os.system(tool + ''.join(commands.mkarg(x) for x in difftool_args))
finally:
shutil.rmtree(tmpdir, ignore_errors=True)

if __name__ == '__main__':
main(sys.argv)

0 comments on commit 0087d68

Please sign in to comment.