comparison hgext/extdiff.py @ 9512:e7bde4680eec

extdiff: add 3-way diff for merge changesets This adds 3-way diff for merge changesets (using -c) and for diffing the working directory context against two parents. To enable it, use the new magic value '$parent2' in the argument line. In order to work, your differ must support that the second parent argument is left out; this will happen in 2-way mode. Default arguments are as before, without enabling 3-way mode, ensuring backwards compatibility. This also fixes a problem when diffing a merge changeset with a single file change. Extdiff would sometimes do the wrong thing in that situation.
author Sune Foldager <cryo@cyanite.org>
date Thu, 17 Sep 2009 21:12:32 +0200
parents 4c041f1ee1b4
children d932dc655881
comparison
equal deleted inserted replaced
9511:33033af09308 9512:e7bde4680eec
40 files, so running the external diff program will actually be pretty 40 files, so running the external diff program will actually be pretty
41 fast (at least faster than having to compare the entire tree). 41 fast (at least faster than having to compare the entire tree).
42 ''' 42 '''
43 43
44 from mercurial.i18n import _ 44 from mercurial.i18n import _
45 from mercurial.node import short 45 from mercurial.node import short, nullid
46 from mercurial import cmdutil, util, commands 46 from mercurial import cmdutil, util, commands
47 import os, shlex, shutil, tempfile 47 import os, shlex, shutil, tempfile, re
48 48
49 def snapshot(ui, repo, files, node, tmproot): 49 def snapshot(ui, repo, files, node, tmproot):
50 '''snapshot files as of some revision 50 '''snapshot files as of some revision
51 if not using snapshot, -I/-X does not work and recursive diff 51 if not using snapshot, -I/-X does not work and recursive diff
52 in tools like kdiff3 and meld displays too many files.''' 52 in tools like kdiff3 and meld displays too many files.'''
67 fns_and_mtime = [] 67 fns_and_mtime = []
68 ctx = repo[node] 68 ctx = repo[node]
69 for fn in files: 69 for fn in files:
70 wfn = util.pconvert(fn) 70 wfn = util.pconvert(fn)
71 if not wfn in ctx: 71 if not wfn in ctx:
72 # skipping new file after a merge ? 72 # File doesn't exist; could be a bogus modify
73 continue 73 continue
74 ui.note(' %s\n' % wfn) 74 ui.note(' %s\n' % wfn)
75 dest = os.path.join(base, wfn) 75 dest = os.path.join(base, wfn)
76 fctx = ctx[wfn] 76 fctx = ctx[wfn]
77 data = repo.wwritedata(wfn, fctx.data()) 77 data = repo.wwritedata(wfn, fctx.data())
94 - just invoke the diff for a single file in the working dir 94 - just invoke the diff for a single file in the working dir
95 ''' 95 '''
96 96
97 revs = opts.get('rev') 97 revs = opts.get('rev')
98 change = opts.get('change') 98 change = opts.get('change')
99 args = ' '.join(diffopts)
100 do3way = '$parent2' in args
99 101
100 if revs and change: 102 if revs and change:
101 msg = _('cannot specify --rev and --change at the same time') 103 msg = _('cannot specify --rev and --change at the same time')
102 raise util.Abort(msg) 104 raise util.Abort(msg)
103 elif change: 105 elif change:
104 node2 = repo.lookup(change) 106 node2 = repo.lookup(change)
105 node1 = repo[node2].parents()[0].node() 107 node1a, node1b = repo.changelog.parents(node2)
106 else: 108 else:
107 node1, node2 = cmdutil.revpair(repo, revs) 109 node1a, node2 = cmdutil.revpair(repo, revs)
110 if not revs:
111 node1b = repo.dirstate.parents()[1]
112 else:
113 node1b = nullid
114
115 # Disable 3-way merge if there is only one parent
116 if do3way:
117 if node1b == nullid:
118 do3way = False
108 119
109 matcher = cmdutil.match(repo, pats, opts) 120 matcher = cmdutil.match(repo, pats, opts)
110 modified, added, removed = repo.status(node1, node2, matcher)[:3] 121 mod_a, add_a, rem_a = map(set, repo.status(node1a, node2, matcher)[:3])
111 if not (modified or added or removed): 122 if do3way:
112 return 0 123 mod_b, add_b, rem_b = map(set, repo.status(node1b, node2, matcher)[:3])
124 else:
125 mod_b, add_b, rem_b = set(), set(), set()
126 modadd = mod_a | add_a | mod_b | add_b
127 common = modadd | rem_a | rem_b
128 if not common:
129 return 0
113 130
114 tmproot = tempfile.mkdtemp(prefix='extdiff.') 131 tmproot = tempfile.mkdtemp(prefix='extdiff.')
115 dir2root = ''
116 try: 132 try:
117 # Always make a copy of node1 133 # Always make a copy of node1a (and node1b, if applicable)
118 dir1 = snapshot(ui, repo, modified + removed, node1, tmproot)[0] 134 dir1a_files = mod_a | rem_a | ((mod_b | add_b) - add_a)
119 changes = len(modified) + len(removed) + len(added) 135 dir1a = snapshot(ui, repo, dir1a_files, node1a, tmproot)[0]
136 if do3way:
137 dir1b_files = mod_b | rem_b | ((mod_a | add_a) - add_b)
138 dir1b = snapshot(ui, repo, dir1b_files, node1b, tmproot)[0]
139 else:
140 dir1b = None
141
142 fns_and_mtime = []
120 143
121 # If node2 in not the wc or there is >1 change, copy it 144 # If node2 in not the wc or there is >1 change, copy it
122 if node2 or changes > 1: 145 dir2root = ''
123 dir2, fns_and_mtime = snapshot(ui, repo, modified + added, node2, tmproot) 146 if node2:
147 dir2 = snapshot(ui, repo, modadd, node2, tmproot)[0]
148 elif len(common) > 1:
149 #we only actually need to get the files to copy back to the working
150 #dir in this case (because the other cases are: diffing 2 revisions
151 #or single file -- in which case the file is already directly passed
152 #to the diff tool).
153 dir2, fns_and_mtime = snapshot(ui, repo, modadd, None, tmproot)
124 else: 154 else:
125 # This lets the diff tool open the changed file directly 155 # This lets the diff tool open the changed file directly
126 dir2 = '' 156 dir2 = ''
127 dir2root = repo.root 157 dir2root = repo.root
128 fns_and_mtime = []
129 158
130 # If only one change, diff the files instead of the directories 159 # If only one change, diff the files instead of the directories
131 if changes == 1 : 160 # Handle bogus modifies correctly by checking if the files exist
132 if len(modified): 161 if len(common) == 1:
133 dir1 = os.path.join(dir1, util.localpath(modified[0])) 162 common_file = util.localpath(common.pop())
134 dir2 = os.path.join(dir2root, dir2, util.localpath(modified[0])) 163 dir1a = os.path.join(dir1a, common_file)
135 elif len(removed) : 164 if not os.path.isfile(os.path.join(tmproot, dir1a)):
136 dir1 = os.path.join(dir1, util.localpath(removed[0])) 165 dir1a = os.devnull
137 dir2 = os.devnull 166 if do3way:
138 else: 167 dir1b = os.path.join(dir1b, common_file)
139 dir1 = os.devnull 168 if not os.path.isfile(os.path.join(tmproot, dir1b)):
140 dir2 = os.path.join(dir2root, dir2, util.localpath(added[0])) 169 dir1b = os.devnull
141 170 dir2 = os.path.join(dir2root, dir2, common_file)
142 cmdline = ('%s %s %s %s' % 171
143 (util.shellquote(diffcmd), ' '.join(diffopts), 172 # Function to quote file/dir names in the argument string
144 util.shellquote(dir1), util.shellquote(dir2))) 173 # When not operating in 3-way mode, an empty string is returned for parent2
174 replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b, child=dir2)
175 def quote(match):
176 key = match.group()[1:]
177 if not do3way and key == 'parent2':
178 return ''
179 return util.shellquote(replace[key])
180
181 # Match parent2 first, so 'parent1?' will match both parent1 and parent
182 regex = '\$(parent2|parent1?|child)'
183 if not do3way and not re.search(regex, args):
184 args += ' $parent1 $child'
185 args = re.sub(regex, quote, args)
186 cmdline = util.shellquote(diffcmd) + ' ' + args
187
145 ui.debug('running %r in %s\n' % (cmdline, tmproot)) 188 ui.debug('running %r in %s\n' % (cmdline, tmproot))
146 util.system(cmdline, cwd=tmproot) 189 util.system(cmdline, cwd=tmproot)
147 190
148 for copy_fn, working_fn, mtime in fns_and_mtime: 191 for copy_fn, working_fn, mtime in fns_and_mtime:
149 if os.path.getmtime(copy_fn) != mtime: 192 if os.path.getmtime(copy_fn) != mtime: