Merge lp:~baztian/bzr/recursive-diff into lp:bzr/2.5

Proposed by Bastian
Status: Rejected
Rejected by: Martin Packman
Proposed branch: lp:~baztian/bzr/recursive-diff
Merge into: lp:bzr/2.5
Diff against target: 402 lines (+278/-10)
5 files modified
bzrlib/builtins.py (+6/-0)
bzrlib/diff.py (+44/-9)
bzrlib/tests/blackbox/test_diff.py (+122/-0)
bzrlib/tests/test_diff.py (+102/-0)
doc/en/release-notes/bzr-2.5.txt (+4/-1)
To merge this branch: bzr merge lp:~baztian/bzr/recursive-diff
Reviewer Review Type Date Requested Status
Vincent Ladeuil Needs Resubmitting
Review via email: mp+97265@code.launchpad.net

Description of the change

Support recursive diff: Show the differences using a custom diff program that supports directory based diff. I would love to have this in 2.5 and 2.6...

To post a comment you must log in.
Revision history for this message
Martin Packman (gz) wrote :

This seems like a new feature, so it's too late for 2.5 really.

I'm not sure about using the template-like @recursive to trigger this, it seems instead like it should happen at the command level. Why did you go for that over a flag on the diff command?

Revision history for this message
Vincent Ladeuil (vila) wrote :

/me nods@mgz about 2.5 being closed for new features.

As 'meld' can already be used without the @recursive flag, I understand why
you put it in the --diff-options, but I too feel it weird. I think it would
be better to have a dedicated option that can be used *only* when --using is
also used, not sure about which name to use but since diff is already
recursive, may be --tree ?

Overall this is a nice feature to have and I'm glad you're working on it. I
wish we have a way to test it better than a review alone allows as I
suspect ignoring stuff you can't diff (in _write_recursive_files) is likely
to break without more testing. That's just a gut feeling though :-/

By the way, are you sure 'meld' can't display symlinks ?

_write_recursive_file, as a name, sounds funny and does not seem to match
the design.

It seems to me that you want to change DiffTree (or rather subclass it or
something) instead of DiffPath. I.e. really diff trees rather than hacking
how one file is diff'ed.

138 + def assert_diff(self, cmd_args, old_files, new_files):
139 + """Assert the correct files have been extracted for the
140 + revisions to the temp folders.

assertExtractedFiles would be more idiomatic.

150 + def assert_files(self, base_path, files):

Wow, this does a lot ! Worth a docstring and comments ;)

Should it be named assertProducedFiles or something ?

This seems to be a nice set of tests, but they are a bit hard too read. For
one, they seem to duplicate some setup that could go into a ... setUp()
method ;)

You may also want to add tests for symlinks (requiring features.Symlink) if only to check and document how your change behaves.

161 + if os.path.islink(filepath):

Someone knowing windows better than me can comment about whether this will
work or fail there ?

314 + def test_finish(self):

test_finish_cleans_up_temp_files ?

332 + def test_finish(self):

'Eager Test' (http://xunitpatterns.com/Obscure%20Test.html) is bad, you shouldn't do that :) 'Defect Localization' (http://xunitpatterns.com/Goals%20of%20Test%20Automation.html#Defect%20Localization) is good, do it !

Can you split it up with meaningful names ?

Please re-target to 2.6 and let's keep the discussion going, this really
sounds like a very cool feature ;)

review: Needs Resubmitting
Revision history for this message
Martin Packman (gz) wrote :

Rejecting for now, please resubmit against lp:bzr if you get the chance.

Unmerged revisions

6261. By Bastian

Show diffs using a custom diff program that supports directory based diff

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bzrlib/builtins.py'
2--- bzrlib/builtins.py 2012-02-16 16:42:43 +0000
3+++ bzrlib/builtins.py 2012-03-13 17:48:37 +0000
4@@ -2276,6 +2276,12 @@
5 Show the differences using a custom diff program with options::
6
7 bzr diff --using /usr/bin/diff --diff-options -wu
8+
9+ Show the differences using a custom diff program that supports
10+ directory based diff::
11+
12+ bzr diff --using meld --diff-options @recursive
13+
14 """
15 _see_also = ['status']
16 takes_args = ['file*']
17
18=== modified file 'bzrlib/diff.py'
19--- bzrlib/diff.py 2011-12-18 12:46:49 +0000
20+++ bzrlib/diff.py 2012-03-13 17:48:37 +0000
21@@ -685,20 +685,26 @@
22
23 class DiffFromTool(DiffPath):
24
25+ _show_diff = False
26+
27 def __init__(self, command_template, old_tree, new_tree, to_file,
28- path_encoding='utf-8'):
29+ path_encoding='utf-8', recursive=False):
30 DiffPath.__init__(self, old_tree, new_tree, to_file, path_encoding)
31 self.command_template = command_template
32+ self._recursive = recursive
33 self._root = osutils.mkdtemp(prefix='bzr-diff-')
34
35 @classmethod
36 def from_string(klass, command_string, old_tree, new_tree, to_file,
37 path_encoding='utf-8'):
38 command_template = cmdline.split(command_string)
39- if '@' not in command_string:
40+ recursive = '@recursive' in command_template
41+ if recursive:
42+ command_template.remove('@recursive')
43+ if set(command_template).isdisjoint(('@old_path', '@new_path')):
44 command_template.extend(['@old_path', '@new_path'])
45 return klass(command_template, old_tree, new_tree, to_file,
46- path_encoding)
47+ path_encoding, recursive)
48
49 @classmethod
50 def make_from_diff_tree(klass, command_string, external_diff_options=None):
51@@ -712,7 +718,7 @@
52
53 def _get_command(self, old_path, new_path):
54 my_map = {'old_path': old_path, 'new_path': new_path}
55- command = [AtTemplate(t).substitute(my_map) for t in
56+ command = [AtTemplate(t).safe_substitute(my_map) for t in
57 self.command_template]
58 if sys.platform == 'win32': # Popen doesn't accept unicode on win32
59 command_encoded = []
60@@ -812,6 +818,21 @@
61 osutils.make_readonly(full_path)
62 return full_path
63
64+ def _write_recursive_file(self, file_id, tree, prefix, relpath, kind):
65+ if kind == 'file':
66+ return self._write_file(file_id, tree, prefix, relpath,
67+ force_temp=True)
68+ else:
69+ full_path = self._safe_filename(prefix, relpath)
70+ if kind == 'directory':
71+ dir_path = full_path
72+ if not kind:
73+ dir_path = osutils.dirname(full_path)
74+ try:
75+ os.makedirs(dir_path)
76+ except OSError:
77+ 'directory already exists'
78+
79 def _prepare_files(self, file_id, old_path, new_path, force_temp=False,
80 allow_write_new=False):
81 old_disk_path = self._write_file(file_id, self.old_tree, 'old',
82@@ -822,6 +843,10 @@
83 return old_disk_path, new_disk_path
84
85 def finish(self):
86+ if self._show_diff:
87+ old_path = osutils.pathjoin(self._root, 'old')
88+ new_path = osutils.pathjoin(self._root, 'new')
89+ self._execute(old_path, new_path)
90 try:
91 osutils.rmtree(self._root)
92 except OSError, e:
93@@ -830,11 +855,21 @@
94 "cleanly removed: %s." % (self._root, e))
95
96 def diff(self, file_id, old_path, new_path, old_kind, new_kind):
97- if (old_kind, new_kind) != ('file', 'file'):
98- return DiffPath.CANNOT_DIFF
99- (old_disk_path, new_disk_path) = self._prepare_files(
100- file_id, old_path, new_path)
101- self._execute(old_disk_path, new_disk_path)
102+ if self._recursive:
103+ allowed_kinds = ('directory', 'file', None)
104+ if not (old_kind in allowed_kinds or new_kind in allowed_kinds):
105+ return self.CANNOT_DIFF
106+ self._show_diff = True
107+ self._write_recursive_file(file_id, self.old_tree, 'old',
108+ old_path, old_kind)
109+ self._write_recursive_file(file_id, self.new_tree, 'new',
110+ new_path, new_kind)
111+ else:
112+ if (old_kind, new_kind) != ('file', 'file'):
113+ return DiffPath.CANNOT_DIFF
114+ (old_disk_path, new_disk_path) = self._prepare_files(
115+ file_id, old_path, new_path)
116+ self._execute(old_disk_path, new_disk_path)
117
118 def edit_file(self, file_id):
119 """Use this tool to edit a file.
120
121=== modified file 'bzrlib/tests/blackbox/test_diff.py'
122--- bzrlib/tests/blackbox/test_diff.py 2012-01-05 13:02:31 +0000
123+++ bzrlib/tests/blackbox/test_diff.py 2012-03-13 17:48:37 +0000
124@@ -26,6 +26,7 @@
125 )
126 from bzrlib.diff import (
127 DiffTree,
128+ DiffFromTool,
129 format_registry as diff_format_registry,
130 )
131 from bzrlib.tests import (
132@@ -414,6 +415,127 @@
133 self.assertEquals('', err)
134
135
136+class TestRecursiveDiff(DiffBase):
137+
138+ def assert_diff(self, cmd_args, old_files, new_files):
139+ """Assert the correct files have been extracted for the
140+ revisions to the temp folders.
141+ """
142+ def execute(other_self, old_path, new_path):
143+ self.assert_files(old_path, old_files)
144+ self.assert_files(new_path, new_files)
145+ self.overrideAttr(DiffFromTool, '_execute', execute)
146+ args = ['diff', '--using=foo', '--diff-options=@recursive']
147+ args += cmd_args.split()
148+ out, err = self.run_bzr(args, retcode=1)
149+
150+ def assert_files(self, base_path, files):
151+ result = []
152+ for (dirpath, dirnames, filenames) in os.walk(base_path):
153+ dir = os.path.relpath(dirpath, base_path)
154+ if not dirnames and not filenames:
155+ # empty dir
156+ if dirpath != base_path:
157+ result.append("%s/" % dir)
158+ continue
159+ for i in filenames + dirnames:
160+ filepath = os.path.join(dirpath, i)
161+ if os.path.islink(filepath):
162+ dest = os.path.join(dir, i)
163+ dest = os.path.relpath(dest)
164+ real_source = os.path.realpath(filepath)
165+ source = os.path.relpath(real_source, base_path)
166+ if os.path.isdir(filepath):
167+ source = "%s/" % source
168+ result.append((dest, source))
169+ elif os.path.isfile(filepath):
170+ filename = os.path.join(dir, i)
171+ filename = os.path.relpath(filename)
172+ result.append(filename)
173+ result = sorted(result)
174+ expected = sorted(files)
175+ self.assertEqual(result, expected)
176+
177+ def test_file_modified(self):
178+ tree = self.make_branch_and_tree('test')
179+ self.build_tree(('test/test.txt',))
180+ tree.add('test.txt')
181+ tree.commit('commit the tree')
182+ self.build_tree_contents((('test/test.txt', 'changed file content'),))
183+ tree.commit('file changed')
184+ self.assert_diff('test -r 1..2', ('test.txt',), ('test.txt',))
185+
186+ def test_file_modified_and_removed(self):
187+ tree = self.make_branch_and_tree('test')
188+ self.build_tree(('test/test.txt',))
189+ tree.add('test.txt')
190+ tree.commit('commit the tree')
191+ self.build_tree_contents((('test/test.txt', 'changed file content'),))
192+ tree.commit('file changed')
193+ tree.remove('test.txt')
194+ tree.commit('file removed')
195+ self.assert_diff('test -r 1..3', ('test.txt',), ())
196+
197+ def test_file_added(self):
198+ tree = self.make_branch_and_tree('test')
199+ self.build_tree(('test/test.txt',))
200+ tree.add('test.txt')
201+ tree.commit('commit the tree')
202+ self.build_tree(('test/test2.txt',))
203+ tree.add('test2.txt')
204+ tree.commit('commit the tree 2')
205+ self.assert_diff('test -r 1..2', (), ('test2.txt',))
206+
207+ def test_file_removed(self):
208+ tree = self.make_branch_and_tree('test')
209+ self.build_tree(('test/test.txt',))
210+ tree.add('test.txt')
211+ tree.commit('commit the tree')
212+ tree.remove('test.txt')
213+ tree.commit('commit the tree 2')
214+ self.assert_diff('test -r 1..2', ('test.txt',), ())
215+
216+ def test_file_renamed(self):
217+ tree = self.make_branch_and_tree('test')
218+ self.build_tree(('test/test.txt',))
219+ tree.add('test.txt')
220+ tree.commit('commit the tree')
221+ tree.rename_one('test.txt', 'test2.txt')
222+ tree.commit('commit the tree 2')
223+ self.assert_diff('test -r 1..2', ('test.txt',), ('test2.txt',))
224+
225+ def test_dir_renamed(self):
226+ tree = self.make_branch_and_tree('test')
227+ self.build_tree(('test/test1/', 'test/test1/test.txt',))
228+ tree.add('test1')
229+ tree.commit('commit the tree')
230+ tree.rename_one('test1', 'test2')
231+ tree.commit('commit the tree 2')
232+ self.assert_diff('test -r 1..2', ('test1/',), ('test2/',))
233+
234+ def test_branches_with_working_tree(self):
235+ tree1 = self.make_branch_and_tree('test1')
236+ self.build_tree(('test1/test.txt',))
237+ tree1.add('test.txt')
238+ tree1.commit('commit the tree')
239+ tree2 = tree1.bzrdir.sprout('test2').open_workingtree()
240+ self.build_tree(('test2/test2.txt',))
241+ tree2.add('test2.txt')
242+ tree2.commit('commit the tree2')
243+ self.assert_diff('test1 --new test2', (), ('test2.txt',))
244+
245+ def test_branches_branch2_without_working_tree(self):
246+ tree1 = self.make_branch_and_tree('test1')
247+ self.build_tree(('test1/test.txt',))
248+ tree1.add('test.txt')
249+ tree1.commit('commit the tree')
250+ tree1.bzrdir.sprout('test2', create_tree_if_local=False)
251+ self.build_tree(('test1/test2.txt',))
252+ tree1.add('test2.txt')
253+ tree1.commit('commit the tree2')
254+ self.assert_diff('test1 --new test2', ('test2.txt',), ())
255+
256+
257 class TestDiffOutput(DiffBase):
258
259 def test_diff_output(self):
260
261=== modified file 'bzrlib/tests/test_diff.py'
262--- bzrlib/tests/test_diff.py 2012-01-25 21:13:15 +0000
263+++ bzrlib/tests/test_diff.py 2012-03-13 17:48:37 +0000
264@@ -1319,6 +1319,45 @@
265 self.assertEqual(['C:\\Tools\\Diff.exe', 'old-path', 'new-path'],
266 diff_obj._get_command('old-path', 'new-path'))
267
268+ def test_from_string_extra_matches(self):
269+ diff_obj = diff.DiffFromTool.from_string('diff @ignoreme',
270+ None, None, None)
271+ self.addCleanup(diff_obj.finish)
272+ self.assertEqual(['diff', '@ignoreme', '@old_path', '@new_path'],
273+ diff_obj.command_template)
274+ self.assertEqual(['diff', '@ignoreme', 'old-path', 'new-path'],
275+ diff_obj._get_command('old-path', 'new-path'))
276+
277+ def _pre_make_from_diff_tree(self, *args):
278+ class DummyDiffTree(object):
279+ old_tree = "old_tree"
280+ new_tree = "new_tree"
281+ to_file = "to_file"
282+ diff_obj = diff.DiffFromTool.make_from_diff_tree(*args)(DummyDiffTree())
283+ self.addCleanup(diff_obj.finish)
284+ self.assertEqual(DummyDiffTree.old_tree, diff_obj.old_tree)
285+ self.assertEqual(DummyDiffTree.new_tree, diff_obj.new_tree)
286+ self.assertEqual(DummyDiffTree.to_file, diff_obj.to_file)
287+ return diff_obj
288+
289+ def test_make_from_diff_tree(self):
290+ diff_obj = self._pre_make_from_diff_tree('diff')
291+ self.assertEqual(['diff', '@old_path', '@new_path'],
292+ diff_obj.command_template)
293+ self.assertFalse(diff_obj._recursive)
294+
295+ def test_make_from_diff_tree_no_path_but_extra_option(self):
296+ diff_obj = self._pre_make_from_diff_tree('diff', 'def')
297+ self.assertEqual(['diff', 'def', '@old_path', '@new_path'],
298+ diff_obj.command_template)
299+ self.assertFalse(diff_obj._recursive)
300+
301+ def test_make_from_diff_tree_recursive(self):
302+ diff_obj = self._pre_make_from_diff_tree('diff', '@recursive')
303+ self.assertEqual(['diff', '@old_path', '@new_path'],
304+ diff_obj.command_template)
305+ self.assertTrue(diff_obj._recursive)
306+
307 def test_execute(self):
308 output = StringIO()
309 diff_obj = diff.DiffFromTool(['python', '-c',
310@@ -1328,6 +1367,15 @@
311 diff_obj._execute('old', 'new')
312 self.assertEqual(output.getvalue().rstrip(), 'old new')
313
314+ def test_finish(self):
315+ output = StringIO()
316+ diff_obj = diff.DiffFromTool(['python', '-c',
317+ 'print "@old_path @new_path"'],
318+ None, None, output)
319+ self.assertTrue(os.path.exists(diff_obj._root))
320+ diff_obj.finish()
321+ self.assertFalse(os.path.exists(diff_obj._root))
322+
323 def test_excute_missing(self):
324 diff_obj = diff.DiffFromTool(['a-tool-which-is-unlikely-to-exist'],
325 None, None, None)
326@@ -1403,6 +1451,60 @@
327 diff_obj._prepare_files('file2-id', 'oldname2', 'newname2')
328
329
330+class TestDiffFromToolRecursive(tests.TestCaseWithTransport):
331+
332+ def test_finish(self):
333+ self.old_tree = self.make_branch_and_tree('old-tree')
334+ self.old_tree.lock_write()
335+ self.addCleanup(self.old_tree.unlock)
336+ self.new_tree = self.make_branch_and_tree('new-tree')
337+ self.new_tree.lock_write()
338+ self.addCleanup(self.new_tree.unlock)
339+ self.build_tree_contents([('old-tree/olddir/',),
340+ ('old-tree/olddir/oldfile', 'old\n'),
341+ ('old-tree/oldfile2', 'old2\n')])
342+ self.old_tree.add('olddir')
343+ self.old_tree.add('olddir/oldfile', 'file-id')
344+ self.old_tree.add('oldfile2', 'file-id2')
345+ self.build_tree_contents([('new-tree/newdir/',),
346+ ('new-tree/newdir/newfile', 'new\n'),
347+ ('new-tree/newfile2', 'new2\n')])
348+ self.new_tree.add('newdir')
349+ self.new_tree.add('newdir/newfile', 'file-id')
350+ self.new_tree.add('newfile2', 'file-id2')
351+
352+ output = StringIO()
353+ diff_obj = diff.DiffFromTool(['python', '-c',
354+ 'print "Foo @old_path @new_path"'],
355+ self.old_tree, self.new_tree,
356+ output, recursive=True)
357+ old_tmp_dir = os.path.join(diff_obj._root, 'old')
358+ new_tmp_dir = os.path.join(diff_obj._root, 'new')
359+
360+ diff_obj.diff('file-id', 'olddir/oldfile', 'newdir/newfile',
361+ 'file', 'file')
362+ self.assertDirContents(old_tmp_dir, ['olddir'])
363+ self.assertDirContents(new_tmp_dir, ['newdir'])
364+ self.assertDirContents(os.path.join(old_tmp_dir, 'olddir'),
365+ ['oldfile'])
366+ self.assertDirContents(os.path.join(new_tmp_dir, 'newdir'),
367+ ['newfile'])
368+
369+ diff_obj.diff('file-id2', 'oldfile2', 'newfile2', 'file', 'file')
370+ self.assertDirContents(old_tmp_dir, ['olddir', 'oldfile2'])
371+ self.assertDirContents(new_tmp_dir, ['newdir', 'newfile2'])
372+ self.assertEqual(output.getvalue(), '')
373+ diff_obj.finish()
374+ self.assertEqual(output.getvalue().rstrip(),
375+ 'Foo %s %s' % (old_tmp_dir, new_tmp_dir))
376+ self.assertFalse(os.path.exists(old_tmp_dir))
377+ self.assertFalse(os.path.exists(new_tmp_dir))
378+
379+ def assertDirContents(self, old_tmp_dir, contents):
380+ old_list = sorted(os.listdir(old_tmp_dir))
381+ self.assertEqual(contents, old_list)
382+
383+
384 class TestDiffFromToolEncodedFilename(tests.TestCaseWithTransport):
385
386 def test_encodable_filename(self):
387
388=== modified file 'doc/en/release-notes/bzr-2.5.txt'
389--- doc/en/release-notes/bzr-2.5.txt 2012-03-12 18:34:04 +0000
390+++ doc/en/release-notes/bzr-2.5.txt 2012-03-13 17:48:37 +0000
391@@ -18,7 +18,10 @@
392 New Features
393 ************
394
395-.. New commands, options, etc that users may wish to try out.
396+* If your external diff tool supports a directory based diff
397+ (recursive diff) you can now specify the @recursive diff
398+ option. This avoids opening a diff tool instance for every differing
399+ file. (Bastian Bowe)
400
401 Improvements
402 ************

Subscribers

People subscribed via source and target branches